@magland/mochi 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/views.js
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encPath = encPath;
|
|
4
|
+
exports.repoUrl = repoUrl;
|
|
5
|
+
exports.csrfField = csrfField;
|
|
6
|
+
exports.layout = layout;
|
|
7
|
+
exports.repoOpts = repoOpts;
|
|
8
|
+
exports.copyButton = copyButton;
|
|
9
|
+
exports.copyRow = copyRow;
|
|
10
|
+
exports.repoHeader = repoHeader;
|
|
11
|
+
exports.homePage = homePage;
|
|
12
|
+
exports.aboutPage = aboutPage;
|
|
13
|
+
exports.collectionPage = collectionPage;
|
|
14
|
+
exports.treePage = treePage;
|
|
15
|
+
exports.searchPage = searchPage;
|
|
16
|
+
exports.findFilePage = findFilePage;
|
|
17
|
+
exports.blobPage = blobPage;
|
|
18
|
+
exports.blamePage = blamePage;
|
|
19
|
+
exports.commitsPage = commitsPage;
|
|
20
|
+
exports.commitPage = commitPage;
|
|
21
|
+
exports.refListPage = refListPage;
|
|
22
|
+
exports.emptyRepoPage = emptyRepoPage;
|
|
23
|
+
exports.errorPage = errorPage;
|
|
24
|
+
const html_1 = require("./html");
|
|
25
|
+
const render_1 = require("./render");
|
|
26
|
+
const session_1 = require("./session");
|
|
27
|
+
const assets_1 = require("./assets");
|
|
28
|
+
const pagescript_1 = require("./pagescript");
|
|
29
|
+
const themes_1 = require("./themes");
|
|
30
|
+
const logo_1 = require("./logo");
|
|
31
|
+
const icons_1 = require("./icons");
|
|
32
|
+
const avatar_1 = require("./avatar");
|
|
33
|
+
const version_1 = require("./version");
|
|
34
|
+
function encPath(p) {
|
|
35
|
+
return p
|
|
36
|
+
.split('/')
|
|
37
|
+
.map(encodeURIComponent)
|
|
38
|
+
.join('/');
|
|
39
|
+
}
|
|
40
|
+
function repoUrl(ctx) {
|
|
41
|
+
return `/${encodeURIComponent(ctx.collection)}/${encodeURIComponent(ctx.repo)}`;
|
|
42
|
+
}
|
|
43
|
+
function csrfField(viewer) {
|
|
44
|
+
return (0, html_1.html) `<input type="hidden" name="csrf" value="${viewer.csrf}">`;
|
|
45
|
+
}
|
|
46
|
+
const FOLDER_ICON = (0, icons_1.icon)('folder', 'icon');
|
|
47
|
+
const FILE_ICON = (0, icons_1.icon)('file', 'icon file');
|
|
48
|
+
const REPO_ICON = (0, icons_1.icon)('repo', 'icon');
|
|
49
|
+
function userBox(opts) {
|
|
50
|
+
const viewer = opts.viewer ?? null;
|
|
51
|
+
if (!viewer) {
|
|
52
|
+
const next = opts.path && opts.path.startsWith('/') ? opts.path : '/';
|
|
53
|
+
return (0, html_1.html) `<a class="btn" href="/login?next=${encodeURIComponent(next)}">Sign in</a>`;
|
|
54
|
+
}
|
|
55
|
+
// The signed-in header is an avatar that opens a menu, as GitHub's is: the
|
|
56
|
+
// name and what you can do with the account are one click away rather than
|
|
57
|
+
// spread across the bar.
|
|
58
|
+
const name = viewer.auth.username;
|
|
59
|
+
const admin = (0, session_1.viewerIsAdmin)(viewer)
|
|
60
|
+
? (0, html_1.html) `<a class="dd-item" href="/admin">${(0, icons_1.icon)('sliders')}<span>Admin</span></a>`
|
|
61
|
+
: '';
|
|
62
|
+
return (0, html_1.html) `<details class="dropdown user-menu">
|
|
63
|
+
<summary aria-label="Account menu">${(0, avatar_1.avatar)(name, 24)}${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
|
|
64
|
+
<div class="dropdown-menu dd-right">
|
|
65
|
+
<div class="dd-section">Signed in as <b>${name}</b></div>
|
|
66
|
+
${admin}
|
|
67
|
+
<form method="post" action="/logout">${csrfField(viewer)}<button type="submit" class="dd-item">${(0, icons_1.icon)('sign-out')}<span>Sign out</span></button></form>
|
|
68
|
+
</div>
|
|
69
|
+
</details>`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The jump box. A vault is many repositories and one collection page, so the
|
|
73
|
+
* way between any two of them was previously up and back down; this is the
|
|
74
|
+
* one control that goes straight there, from every page, on one keystroke.
|
|
75
|
+
* The button carries the keystroke on its face, since a shortcut nothing
|
|
76
|
+
* mentions is a shortcut nobody finds.
|
|
77
|
+
*/
|
|
78
|
+
function jumpButton() {
|
|
79
|
+
return (0, html_1.html) `<button type="button" class="jump-open" data-jump-open aria-haspopup="dialog" aria-label="Jump to a repository">${(0, icons_1.icon)('search')}<span class="jump-label">Jump to</span><kbd class="jump-key">/</kbd></button>`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Which appearance the reader wants. The vault's theme is what the operator
|
|
83
|
+
* chose and stays the default, but the reader is the one looking at it, on
|
|
84
|
+
* their screen and at their hour, so they get the last word. The choice is
|
|
85
|
+
* kept in their own browser: nothing about it reaches the vault, and a vault
|
|
86
|
+
* that is read anonymously has nowhere to keep it anyway.
|
|
87
|
+
*/
|
|
88
|
+
function themeMenu() {
|
|
89
|
+
const item = (name, label) => (0, html_1.html) `<button type="button" class="dd-item theme-item" role="menuitemradio" aria-checked="false" data-theme-name="${name}"><span class="theme-check">${(0, icons_1.icon)('check')}</span><span>${label}</span></button>`;
|
|
90
|
+
return (0, html_1.html) `<details class="dropdown theme-menu">
|
|
91
|
+
<summary aria-label="Appearance">${(0, icons_1.icon)('appearance')}</summary>
|
|
92
|
+
<div class="dropdown-menu dd-right" role="menu">
|
|
93
|
+
<div class="dd-section">Appearance</div>
|
|
94
|
+
${item('auto', 'Match my system')}
|
|
95
|
+
${(0, html_1.joinHtml)(themes_1.THEMES.map((t) => item(t.name, t.label)), '\n ')}
|
|
96
|
+
</div>
|
|
97
|
+
</details>`;
|
|
98
|
+
}
|
|
99
|
+
function jumpDialog(jump) {
|
|
100
|
+
const data = jump
|
|
101
|
+
? // The payload is JSON in a script tag, where escaping entities would
|
|
102
|
+
// corrupt it; < is neutralized in the JSON's own spelling instead.
|
|
103
|
+
(0, html_1.html) `<script type="application/json" id="jump-data">${(0, html_1.raw)(JSON.stringify(jump).replace(/</g, '\\u003c'))}</script>`
|
|
104
|
+
: '';
|
|
105
|
+
return (0, html_1.html) `${data}<dialog class="jump" id="jump" aria-label="Jump to">
|
|
106
|
+
<div class="jump-field">${(0, icons_1.icon)('search', 'jump-glyph')}<input id="jump-q" type="text" autocomplete="off" spellcheck="false" placeholder="Jump to a repository" aria-label="Jump to a repository" aria-controls="jump-list"></div>
|
|
107
|
+
<ul class="jump-list" id="jump-list" role="listbox" aria-label="Results"></ul>
|
|
108
|
+
<div class="jump-foot"><kbd>↑</kbd><kbd>↓</kbd> move<kbd>↵</kbd>open<kbd>esc</kbd>close</div>
|
|
109
|
+
</dialog>`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* What this vault is running, at the foot of every page. An operator who has
|
|
113
|
+
* just deployed something needs to know whether they are looking at it, and a
|
|
114
|
+
* version alone cannot say: main carries the last release's version until the
|
|
115
|
+
* next bump, so several different builds answer to "0.3.0". The commit narrows
|
|
116
|
+
* it to one and the date says when that one was compiled. A vault running from
|
|
117
|
+
* source has no build to name and says that instead of showing a date it would
|
|
118
|
+
* have had to invent. See src/version.ts for where the stamp comes from.
|
|
119
|
+
*/
|
|
120
|
+
function buildStamp() {
|
|
121
|
+
const build = (0, version_1.buildInfo)();
|
|
122
|
+
const parts = [(0, html_1.html) `Mochi Forge <span class="mono">${build.version}</span>`];
|
|
123
|
+
if (build.commit)
|
|
124
|
+
parts.push((0, html_1.html) `build <span class="mono">${build.commit}</span>`);
|
|
125
|
+
const iso = build.builtAt;
|
|
126
|
+
const built = iso ? new Date(iso) : null;
|
|
127
|
+
parts.push(iso && built && !isNaN(built.getTime())
|
|
128
|
+
? // The day, not "3 days ago": what this gets compared against is a date
|
|
129
|
+
// kept somewhere else, a release or a deploy log, and the exact time is
|
|
130
|
+
// a hover away for anyone telling two builds of one day apart.
|
|
131
|
+
(0, html_1.html) `built <time datetime="${built.toISOString()}" title="${(0, render_1.formatDateFull)(iso)}">${(0, render_1.formatDay)(iso)}</time>`
|
|
132
|
+
: (0, html_1.html) `running from source`);
|
|
133
|
+
return (0, html_1.joinHtml)(parts, ' <span class="foot-sep">\u00b7</span> ');
|
|
134
|
+
}
|
|
135
|
+
function layout(title, content, opts = {}) {
|
|
136
|
+
// The theme name rides along as a query parameter so a changed theme busts
|
|
137
|
+
// any cache in front of the stylesheets, and the stylesheet carries a tag of
|
|
138
|
+
// its own contents so that an upgrade does too. Between them the sheet can
|
|
139
|
+
// then be kept for good instead of revalidated on every navigation.
|
|
140
|
+
const theme = (0, themes_1.activeTheme)().name;
|
|
141
|
+
const sheet = (0, assets_1.styleSheet)((0, themes_1.activeTheme)()).tag;
|
|
142
|
+
const script = (0, pagescript_1.pageScript)().tag;
|
|
143
|
+
// The theme pair rides on <html> rather than in the script, which is what
|
|
144
|
+
// lets /assets/page.js be one cacheable file for every vault: the script
|
|
145
|
+
// reads these two attributes instead of being generated around them.
|
|
146
|
+
return (0, html_1.html) `<!doctype html>
|
|
147
|
+
<html lang="en" data-theme-vault="${theme}" data-theme-dark="${(0, themes_1.darkFor)((0, themes_1.activeTheme)())}">
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8">
|
|
150
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
151
|
+
<title>${title}</title>
|
|
152
|
+
<link rel="stylesheet" href="/assets/style.css?t=${encodeURIComponent(theme)}&v=${sheet}">
|
|
153
|
+
<link id="hl-css" rel="stylesheet" href="/assets/hl.css?t=${encodeURIComponent(theme)}">
|
|
154
|
+
<link rel="stylesheet" href="/assets/katex/katex.css">
|
|
155
|
+
<link rel="icon" href="/favicon.svg?t=${encodeURIComponent(theme)}" type="image/svg+xml">
|
|
156
|
+
<script src="/assets/page.js?v=${script}"></script>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<header class="topbar"><div class="container"><a class="brand" href="/">${(0, html_1.raw)(logo_1.WORDMARK)}</a><span class="crumbs">${opts.crumbs}</span><div class="userbox">${jumpButton()}${themeMenu()}${userBox(opts)}</div></div></header>
|
|
160
|
+
<main class="container">
|
|
161
|
+
${content}
|
|
162
|
+
</main>
|
|
163
|
+
<footer class="pagefoot"><div class="container">${buildStamp()}</div></footer>
|
|
164
|
+
${jumpDialog(opts.jump ?? null)}
|
|
165
|
+
</body>
|
|
166
|
+
</html>`.text;
|
|
167
|
+
}
|
|
168
|
+
function repoOpts(ctx, path) {
|
|
169
|
+
// Every repository page puts its address in the top bar as well as at the
|
|
170
|
+
// head of the page. The bar is what stays in view once the reader has
|
|
171
|
+
// scrolled into a long file or a long thread, so it is the one place that
|
|
172
|
+
// can always answer "where am I, and how do I get back up from here".
|
|
173
|
+
const crumbs = (0, html_1.html) ` / <a href="/${encodeURIComponent(ctx.collection)}">${ctx.collection}</a> / <a href="${repoUrl(ctx)}">${ctx.repo}</a>`;
|
|
174
|
+
return { viewer: ctx.viewer, path, crumbs, jump: jumpContext(ctx) };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* What the jump box offers from inside a repository: the sections its tabs
|
|
178
|
+
* lead to, and the two searches that take what has been typed rather than
|
|
179
|
+
* matching against it. The list mirrors the tab row, so a section that is not
|
|
180
|
+
* shown there is not reachable here either.
|
|
181
|
+
*/
|
|
182
|
+
function jumpContext(ctx) {
|
|
183
|
+
const base = repoUrl(ctx);
|
|
184
|
+
const sections = [
|
|
185
|
+
{ label: 'Code', href: base },
|
|
186
|
+
{ label: 'Commits', href: `${base}/commits/${encPath(ctx.ref)}` },
|
|
187
|
+
{ label: 'Issues', href: `${base}/issues` },
|
|
188
|
+
{ label: 'Pull requests', href: `${base}/pulls` },
|
|
189
|
+
{ label: 'Branches', href: `${base}/branches` },
|
|
190
|
+
{ label: 'Tags', href: `${base}/tags` },
|
|
191
|
+
{ label: 'Releases', href: `${base}/releases` },
|
|
192
|
+
];
|
|
193
|
+
if (ctx.hasCi)
|
|
194
|
+
sections.push({ label: 'Actions', href: `${base}/actions` });
|
|
195
|
+
if (ctx.hasSite)
|
|
196
|
+
sections.push({ label: 'Site', href: ctx.siteUrl });
|
|
197
|
+
if (ctx.canPush || ctx.canAdmin)
|
|
198
|
+
sections.push({ label: 'Settings', href: `${base}/settings` });
|
|
199
|
+
return {
|
|
200
|
+
repo: ctx.repo,
|
|
201
|
+
sections,
|
|
202
|
+
searchUrl: `${base}/search?ref=${encodeURIComponent(ctx.ref)}&q=`,
|
|
203
|
+
findUrl: `${base}/find/${encPath(ctx.ref)}`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The copy button that sits after a <code> or <input> holding the text.
|
|
208
|
+
* Confirmation is a class the script toggles rather than replaced markup, so
|
|
209
|
+
* the idle and copied faces are both in the page and neither needs escaping
|
|
210
|
+
* at click time.
|
|
211
|
+
*/
|
|
212
|
+
function copyButton(label = '', text, title = '') {
|
|
213
|
+
const face = (glyph, caption, cls) => (0, html_1.html) `<span class="${cls}">${(0, icons_1.icon)(glyph)}${label ? (0, html_1.html) `<span>${caption}</span>` : ''}</span>`;
|
|
214
|
+
// With no text of its own the button copies the element before it, which is
|
|
215
|
+
// how the command rows and the clone box use it.
|
|
216
|
+
const payload = text === undefined ? '' : (0, html_1.html) ` data-copy="${text}"`;
|
|
217
|
+
return (0, html_1.html) `<button class="copy-btn" type="button"${payload}${title ? (0, html_1.html) ` title="${title}"` : ''} aria-label="Copy${label ? ` ${label.toLowerCase()}` : ''}">${face('copy', label, 'copy-idle')}${face('check', 'Copied', 'copy-done')}</button>`;
|
|
218
|
+
}
|
|
219
|
+
function copyRow(cmd) {
|
|
220
|
+
return (0, html_1.html) `<div class="cmd-row"><code>${cmd}</code>${copyButton()}</div>`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The branch and tag picker: a button carrying the current ref, opening a
|
|
224
|
+
* menu of every ref with a filter box, as on GitHub. It is a <details>
|
|
225
|
+
* element, so it opens, closes, and takes the keyboard without a component
|
|
226
|
+
* framework; the page script closes it on an outside click and filters the
|
|
227
|
+
* list as you type.
|
|
228
|
+
*/
|
|
229
|
+
function refPicker(ctx, urlForRef) {
|
|
230
|
+
const isTag = ctx.tags.some((t) => t.name === ctx.ref);
|
|
231
|
+
const known = isTag || ctx.branches.some((b) => b.name === ctx.ref);
|
|
232
|
+
const item = (r) => {
|
|
233
|
+
const current = r.name === ctx.ref;
|
|
234
|
+
return (0, html_1.html) `<a class="dd-item${current ? ' current' : ''}" href="${urlForRef(r.name)}">${current ? (0, icons_1.icon)('check', 'dd-check') : (0, html_1.raw)('<span class="dd-check"></span>')}<span class="dd-label">${r.name}</span></a>`;
|
|
235
|
+
};
|
|
236
|
+
const group = (label, refs) => refs.length === 0
|
|
237
|
+
? ''
|
|
238
|
+
: (0, html_1.html) `<div class="dd-group"><div class="dd-section">${label}</div>${refs.map(item)}</div>`;
|
|
239
|
+
// A ref that is neither branch nor tag is a raw commit the reader navigated
|
|
240
|
+
// to; name it on the button so the picker never lies about where they are.
|
|
241
|
+
const glyph = known ? (isTag ? 'tag' : 'git-branch') : 'git-commit';
|
|
242
|
+
const shown = known ? ctx.ref : ctx.ref.slice(0, 7);
|
|
243
|
+
return (0, html_1.html) `<details class="dropdown ref-picker">
|
|
244
|
+
<summary class="btn" title="Switch branches or tags">${(0, icons_1.icon)(glyph)}<b class="dd-current">${shown}</b>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
|
|
245
|
+
<div class="dropdown-menu">
|
|
246
|
+
<input class="dd-filter" type="text" placeholder="Find a branch or tag" aria-label="Filter branches and tags">
|
|
247
|
+
<div class="dd-scroll">${group('Branches', ctx.branches)}${group('Tags', ctx.tags)}</div>
|
|
248
|
+
</div>
|
|
249
|
+
</details>`;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The way in to the file finder. The data attribute is what the page script
|
|
253
|
+
* watches for, so that t reaches the finder from any page that offers it, as
|
|
254
|
+
* on GitHub.
|
|
255
|
+
*/
|
|
256
|
+
function findButton(ctx) {
|
|
257
|
+
const href = `${repoUrl(ctx)}/find/${encPath(ctx.ref)}`;
|
|
258
|
+
return (0, html_1.html) `<a class="btn" href="${href}" data-find-url="${href}" title="Go to file (t)">${(0, icons_1.icon)('search')}<span>Go to file</span></a>`;
|
|
259
|
+
}
|
|
260
|
+
/** The green Code button: the clone URL, and the source as an archive. */
|
|
261
|
+
function cloneMenu(ctx) {
|
|
262
|
+
const archive = (ext, label) => (0, html_1.html) `<a class="dd-item" href="${repoUrl(ctx)}/archive/${encPath(ctx.ref)}.${ext}">${(0, icons_1.icon)('file-zip')}<span class="dd-label">${label}</span></a>`;
|
|
263
|
+
return (0, html_1.html) `<details class="dropdown clone-menu">
|
|
264
|
+
<summary class="btn btn-primary">${(0, icons_1.icon)('code')}<span>Code</span>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
|
|
265
|
+
<div class="dropdown-menu dd-right">
|
|
266
|
+
<div class="dd-section">Clone with HTTP</div>
|
|
267
|
+
<div class="cmd-row"><input readonly value="${ctx.cloneUrl}" data-select-all>${copyButton()}</div>
|
|
268
|
+
<p class="muted small">Anyone can clone. Pushing asks for a username and a token.</p>
|
|
269
|
+
<div class="dd-group"><div class="dd-section">Download ${ctx.ref}</div>
|
|
270
|
+
${archive('zip', 'Source as zip')}${archive('tar.gz', 'Source as tar.gz')}</div>
|
|
271
|
+
</div>
|
|
272
|
+
</details>`;
|
|
273
|
+
}
|
|
274
|
+
function repoHeader(ctx, active) {
|
|
275
|
+
const base = repoUrl(ctx);
|
|
276
|
+
const tab = (id, label, href, glyph, count) => (0, html_1.html) `<a class="tab${active === id ? ' active' : ''}" href="${href}">${(0, icons_1.icon)(glyph)}<span>${label}</span>${count !== undefined ? (0, html_1.html) `<span class="counter">${count}</span>` : ''}</a>`;
|
|
277
|
+
// The search box rides in the title row, so searching this repository is a
|
|
278
|
+
// keystroke away from every one of its pages.
|
|
279
|
+
const search = (0, html_1.html) `<form class="repo-search" method="get" action="${base}/search" role="search"><input type="hidden" name="ref" value="${ctx.ref}"><input class="search-input" type="search" name="q" placeholder="Search this repository" aria-label="Search this repository">${(0, icons_1.icon)('search', 'search-glyph')}</form>`;
|
|
280
|
+
const parent = ctx.forkedFrom
|
|
281
|
+
? (0, html_1.html) `<div class="fork-note muted small">${(0, icons_1.icon)('repo-forked')}<span>forked from <a href="/${encodeURIComponent(ctx.forkedFrom.collection)}/${encodeURIComponent(ctx.forkedFrom.repo)}">${ctx.forkedFrom.collection}/${ctx.forkedFrom.repo}</a></span></div>`
|
|
282
|
+
: '';
|
|
283
|
+
const badge = ctx.isPrivate ? (0, html_1.html) ` <span class="counter" title="Only collaborators, owners, and site admins can see this repository">Private</span>` : '';
|
|
284
|
+
return (0, html_1.html) `<div class="repo-title">${REPO_ICON}<a href="/${encodeURIComponent(ctx.collection)}">${ctx.collection}</a> <span class="muted">/</span> <a href="${base}"><b>${ctx.repo}</b></a>${badge}${search}</div>
|
|
285
|
+
${parent}
|
|
286
|
+
<nav class="tabs">
|
|
287
|
+
${tab('code', 'Code', base, 'code')}
|
|
288
|
+
${tab('commits', 'Commits', `${base}/commits/${encPath(ctx.ref)}`, 'history')}
|
|
289
|
+
${tab('issues', 'Issues', `${base}/issues`, 'issue-opened', ctx.openIssues)}
|
|
290
|
+
${tab('pulls', 'Pull requests', `${base}/pulls`, 'git-pull-request', ctx.openPulls)}
|
|
291
|
+
${ctx.hasCi || active === 'actions' ? tab('actions', 'Actions', `${base}/actions`, 'play') : ''}
|
|
292
|
+
${tab('branches', 'Branches', `${base}/branches`, 'git-branch', ctx.branches.length)}
|
|
293
|
+
${tab('tags', 'Tags', `${base}/tags`, 'tag', ctx.tags.length)}
|
|
294
|
+
${ctx.releases.length || active === 'releases' ? tab('releases', 'Releases', `${base}/releases`, 'rocket', ctx.releases.length) : ''}
|
|
295
|
+
${ctx.hasSite ? tab('site', 'Site', ctx.siteUrl, 'globe') : ''}
|
|
296
|
+
${ctx.canPush || ctx.canAdmin ? tab('settings', 'Settings', `${base}/settings`, 'sliders') : ''}
|
|
297
|
+
</nav>`;
|
|
298
|
+
}
|
|
299
|
+
function breadcrumb(ctx, path) {
|
|
300
|
+
const base = repoUrl(ctx);
|
|
301
|
+
const parts = path === '' ? [] : path.split('/');
|
|
302
|
+
const pieces = [(0, html_1.html) `<a href="${base}/tree/${encPath(ctx.ref)}">${ctx.repo}</a>`];
|
|
303
|
+
let acc = '';
|
|
304
|
+
parts.forEach((part, i) => {
|
|
305
|
+
acc = acc === '' ? part : `${acc}/${part}`;
|
|
306
|
+
const last = i === parts.length - 1;
|
|
307
|
+
if (last) {
|
|
308
|
+
pieces.push((0, html_1.html) `<b>${part}</b>`);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
pieces.push((0, html_1.html) `<a href="${base}/tree/${encPath(ctx.ref)}/${encPath(acc)}">${part}</a>`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
return (0, html_1.html) `<span class="crumb">${(0, html_1.joinHtml)(pieces, ' / ')}</span>`;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The "find a repository" box GitHub puts above a long listing. It filters
|
|
318
|
+
* rows in the page rather than asking the server, which is honest about what
|
|
319
|
+
* it is: a way to find a name you already know in a list you can already see.
|
|
320
|
+
* Short lists do not get one, since scanning five names is faster than typing.
|
|
321
|
+
*/
|
|
322
|
+
function listFilter(target, placeholder, rowCount) {
|
|
323
|
+
if (rowCount <= 5)
|
|
324
|
+
return '';
|
|
325
|
+
return (0, html_1.html) `<div class="toolbar"><div class="left"><input class="list-filter" type="text" placeholder="${placeholder}" data-target="${target}" data-filter="rows" aria-label="${placeholder}"></div></div>`;
|
|
326
|
+
}
|
|
327
|
+
function noMatches(target) {
|
|
328
|
+
return (0, html_1.html) `<div class="empty-state" id="${target}-empty" hidden>No match.</div>`;
|
|
329
|
+
}
|
|
330
|
+
const CI_MARKS = {
|
|
331
|
+
running: { glyph: 'dot', label: 'running' },
|
|
332
|
+
success: { glyph: 'check-circle', label: 'passed' },
|
|
333
|
+
failure: { glyph: 'x-circle', label: 'failed' },
|
|
334
|
+
cancelled: { glyph: 'stop', label: 'cancelled' },
|
|
335
|
+
skipped: { glyph: 'skip', label: 'skipped' },
|
|
336
|
+
};
|
|
337
|
+
function ciMark(ci, repoName) {
|
|
338
|
+
const state = ci.running ? 'running' : (ci.conclusion ?? 'skipped');
|
|
339
|
+
const mark = CI_MARKS[state] ?? CI_MARKS.skipped;
|
|
340
|
+
return (0, html_1.html) `<a class="ci-mark ci-${state}" href="${ci.url}" aria-label="Last workflow run for ${repoName}: ${mark.label}" title="Last workflow run ${mark.label}">${(0, icons_1.icon)(mark.glyph)}</a>`;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A repository in a listing. Everything a reader decides on from a list of
|
|
344
|
+
* names is here and nothing else is: what it is called, what it is, when it
|
|
345
|
+
* was last touched, whether it publishes a site, and whether its last build
|
|
346
|
+
* passed. The name is the only link that fills the card, so the site and the
|
|
347
|
+
* build are reachable without opening the repository to find them.
|
|
348
|
+
*/
|
|
349
|
+
function repoCard(r, showCollection) {
|
|
350
|
+
const href = `/${encodeURIComponent(r.collection)}/${encodeURIComponent(r.name)}`;
|
|
351
|
+
const prefix = showCollection ? (0, html_1.html) `<span class="rc-collection">${r.collection}/</span>` : '';
|
|
352
|
+
const site = r.siteUrl
|
|
353
|
+
? (0, html_1.html) `<a class="site-link" href="${r.siteUrl}" title="Site" aria-label="Site for ${r.name}">${(0, icons_1.icon)('globe')}</a>`
|
|
354
|
+
: '';
|
|
355
|
+
const ci = r.ci ? ciMark(r.ci, r.name) : '';
|
|
356
|
+
const desc = r.description ? (0, html_1.html) `<p class="rc-desc">${r.description}</p>` : '';
|
|
357
|
+
const when = r.updated ? (0, html_1.html) `<span class="rc-when">${(0, render_1.timeTag)(r.updated)}</span>` : '';
|
|
358
|
+
const badge = r.isPrivate ? (0, html_1.html) `<span class="counter">Private</span>` : '';
|
|
359
|
+
return (0, html_1.html) `<li class="repo-card">
|
|
360
|
+
<div class="rc-top"><a class="rc-name" href="${href}">${prefix}${r.name}</a>${badge}<span class="rc-marks">${site}${ci}</span></div>
|
|
361
|
+
${desc}
|
|
362
|
+
<div class="rc-meta">${when}</div>
|
|
363
|
+
</li>`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The listing itself. Sorting is a link rather than a script, so the order a
|
|
367
|
+
* reader chose is in the address they can keep, and the two orders answer the
|
|
368
|
+
* two questions a listing is asked: what has been happening lately, and where
|
|
369
|
+
* is the one I already know the name of.
|
|
370
|
+
*/
|
|
371
|
+
function repoListing(repos, opts) {
|
|
372
|
+
const sorted = [...repos];
|
|
373
|
+
if (opts.sort === 'name') {
|
|
374
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name) || a.collection.localeCompare(b.collection));
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
sorted.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''));
|
|
378
|
+
}
|
|
379
|
+
const sortLink = (id, label) => (0, html_1.html) `<a${opts.sort === id ? (0, html_1.raw)(' class="current" aria-current="true"') : ''} href="${opts.sortBase}${id === 'recent' ? '' : `?sort=${id}`}">${label}</a>`;
|
|
380
|
+
const controls = (0, html_1.html) `<div class="listing-controls">
|
|
381
|
+
<input class="list-filter" type="text" placeholder="Filter repositories" data-target="repo-list" data-filter="cards" aria-label="Filter repositories">
|
|
382
|
+
<span class="seg">${sortLink('recent', 'Recent')}${sortLink('name', 'A–Z')}</span>
|
|
383
|
+
</div>`;
|
|
384
|
+
return (0, html_1.html) `${repos.length > 5 ? controls : ''}<ul class="repo-grid" id="repo-list">${(0, html_1.joinHtml)(sorted.map((r) => repoCard(r, opts.showCollection)), '\n')}</ul>${noMatches('repo-list')}`;
|
|
385
|
+
}
|
|
386
|
+
function homePage(rootLabel, collections, repos, sort, viewer) {
|
|
387
|
+
const total = repos.length;
|
|
388
|
+
const chips = collections.length
|
|
389
|
+
? (0, html_1.html) `<nav class="collection-chips" aria-label="Collections">${collections.map((c) => (0, html_1.html) `<a class="coll-chip" href="/${encodeURIComponent(c.name)}">${(0, avatar_1.avatar)(c.name, 18, 'square')}<span>${c.name}</span><span class="coll-count">${c.repoCount}</span></a>`)}</nav>`
|
|
390
|
+
: '';
|
|
391
|
+
const body = total === 0
|
|
392
|
+
? (0, html_1.html) `<div class="empty-state">No repositories yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''} <a href="/about">What is this?</a></div>`
|
|
393
|
+
: repoListing(repos, { showCollection: true, sort, sortBase: '/' });
|
|
394
|
+
const newBtn = viewer
|
|
395
|
+
? (0, html_1.html) `<a class="btn" href="/new/collection">${(0, icons_1.icon)('plus')}<span>New collection</span></a><a class="btn btn-primary" href="/new">${(0, icons_1.icon)('plus')}<span>New repository</span></a>`
|
|
396
|
+
: '';
|
|
397
|
+
// The about link is how a visitor who landed here cold finds out what they
|
|
398
|
+
// are looking at, so it rides on the first line they read.
|
|
399
|
+
const summary = total === 0
|
|
400
|
+
? ''
|
|
401
|
+
: (0, html_1.html) `<p class="lede">${total} ${total === 1 ? 'repository' : 'repositories'} in ${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}. <a class="muted" href="/about">What is this?</a></p>`;
|
|
402
|
+
const content = (0, html_1.html) `<div class="page-head"><h1>Repositories</h1><span class="right-group">${newBtn}</span></div>
|
|
403
|
+
${summary}
|
|
404
|
+
${chips}
|
|
405
|
+
${body}
|
|
406
|
+
${
|
|
407
|
+
// Where the vault sits on disk is the operator's business and not a
|
|
408
|
+
// visitor's, so the path is shown to someone who could act on it.
|
|
409
|
+
(0, session_1.viewerIsAdmin)(viewer) ? (0, html_1.html) `<p class="muted small vault-note">Serving ${rootLabel}</p>` : ''}`;
|
|
410
|
+
return layout('Mochi Forge', content, { viewer, path: '/' });
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* What this place is and how to use it, for the visitor who landed on a
|
|
414
|
+
* listing with no idea what they are looking at. One page, self-contained:
|
|
415
|
+
* the vault serves no other documentation, so nothing here may point into
|
|
416
|
+
* docs a visitor cannot reach.
|
|
417
|
+
*/
|
|
418
|
+
function aboutPage(baseUrl, viewer) {
|
|
419
|
+
const content = (0, html_1.html) `<div class="about-page">
|
|
420
|
+
<h1>About this vault</h1>
|
|
421
|
+
<p class="lede">This site is a <i>vault</i>: a self-hosted git forge run by its operator, holding git repositories
|
|
422
|
+
grouped into collections.</p>
|
|
423
|
+
|
|
424
|
+
<h2>Reading</h2>
|
|
425
|
+
<p>Reading is anonymous. Browse files, history, and diffs; read issues and pull requests; search inside a repository
|
|
426
|
+
(press <span class="mono">/</span> anywhere); download any ref as an archive. Cloning needs no account either:</p>
|
|
427
|
+
${copyRow(`git clone ${baseUrl}/<collection>/<repository>`)}
|
|
428
|
+
|
|
429
|
+
<h2>Writing</h2>
|
|
430
|
+
<p>Every write is authorized by a token. There is no sign-up: tokens are minted by this vault's administrator, so if you
|
|
431
|
+
should be able to push here, ask whoever runs the vault. A token is used two ways, with your username: as the password
|
|
432
|
+
when git asks for credentials, and to <a href="/login">sign in</a> on the web, which unlocks editing files, opening
|
|
433
|
+
issues and pull requests, and drafting releases in the browser.</p>
|
|
434
|
+
<p>Pushing to a repository that does not exist yet creates it, if your token's scope covers that path:</p>
|
|
435
|
+
${copyRow(`git push ${baseUrl}/<collection>/<new-repository> main`)}
|
|
436
|
+
|
|
437
|
+
<h2>What repositories carry</h2>
|
|
438
|
+
<p>Beside its files, a repository here can have issues, pull requests, releases tied to tags, GitHub-style workflows run
|
|
439
|
+
by the operator's own runners, and a static site served from the repository at
|
|
440
|
+
<span class="mono">/<collection>/<repository>/site/</span>. Workflows run without credentials: a vault holds
|
|
441
|
+
no secrets, so jobs are limited to work that needs none, such as building and publishing a site.</p>
|
|
442
|
+
|
|
443
|
+
<h2>The software</h2>
|
|
444
|
+
<p>This vault runs <a href="https://github.com/magland/mochi">Mochi Forge</a>, an open-source forge: one process, no
|
|
445
|
+
database, every repository an ordinary bare git repository on the operator's own disk.</p>
|
|
446
|
+
</div>`;
|
|
447
|
+
return layout('About - Mochi Forge', content, { viewer, path: '/about' });
|
|
448
|
+
}
|
|
449
|
+
function collectionPage(collection, repoList, sort, viewer,
|
|
450
|
+
// Whether the viewer may reach the collection's settings, which is the only
|
|
451
|
+
// way to the rename. A control nobody can use is not shown, as elsewhere.
|
|
452
|
+
canAdminCollection,
|
|
453
|
+
// The collection's profile README, read from its .mochi repository; see
|
|
454
|
+
// src/profile.ts. Rendered above the listing, since it is what the page is
|
|
455
|
+
// for once a collection has one.
|
|
456
|
+
profile = null) {
|
|
457
|
+
const body = repoList.length === 0
|
|
458
|
+
? (0, html_1.html) `<div class="empty-state">No repositories in this collection yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''}</div>`
|
|
459
|
+
: repoListing(repoList, {
|
|
460
|
+
showCollection: false,
|
|
461
|
+
sort,
|
|
462
|
+
sortBase: `/${encodeURIComponent(collection)}`,
|
|
463
|
+
});
|
|
464
|
+
const settingsBtn = canAdminCollection
|
|
465
|
+
? (0, html_1.html) `<a class="btn" href="/${encodeURIComponent(collection)}/settings">${(0, icons_1.icon)('sliders')}<span>Settings</span></a>`
|
|
466
|
+
: '';
|
|
467
|
+
const newBtn = viewer
|
|
468
|
+
? (0, html_1.html) `<a class="btn" href="/import?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('download')}<span>Import</span></a><a class="btn btn-primary" href="/new?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('plus')}<span>New repository</span></a>`
|
|
469
|
+
: '';
|
|
470
|
+
// The prompt to write a profile goes only to a viewer who could administer
|
|
471
|
+
// the collection, and only while there is none: a page with a profile says
|
|
472
|
+
// what it has to say, and the file is edited from the repository holding it.
|
|
473
|
+
const profileBox = profile?.readme
|
|
474
|
+
? (0, html_1.html) `<div class="box profile-box" id="profile"><div class="box-header">${(0, icons_1.icon)('book')}<a href="${profile.readme.url}">${profile.readme.name}</a></div><div class="box-body markdown-body">${(0, html_1.raw)(profile.readme.html)}</div></div>`
|
|
475
|
+
: profile && canAdminCollection
|
|
476
|
+
? (0, html_1.html) `<p class="muted small profile-hint">This collection has no profile README. <a href="${profile.addUrl}">Add one</a> at <span class="mono">.mochi/profile/README.md</span> to introduce it here.</p>`
|
|
477
|
+
: '';
|
|
478
|
+
const content = (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 28, 'square')}${collection}</h1><span class="right-group">${settingsBtn}${newBtn}</span></div>${profileBox}${body}`;
|
|
479
|
+
return layout(collection, content, {
|
|
480
|
+
crumbs: (0, html_1.html) ` / <a href="/${encodeURIComponent(collection)}">${collection}</a>`,
|
|
481
|
+
viewer,
|
|
482
|
+
path: `/${encodeURIComponent(collection)}`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/** "1,284" - counts in the interface are grouped, as they are on GitHub. */
|
|
486
|
+
function count(n) {
|
|
487
|
+
return n.toLocaleString('en-US');
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* What the repository is written in: one bar in the languages' own colours and
|
|
491
|
+
* the list that names them. The bar is the same information as the list and no
|
|
492
|
+
* more, so it is hidden from a screen reader rather than repeated to one.
|
|
493
|
+
*
|
|
494
|
+
* The colours are inline because they belong to the languages rather than to
|
|
495
|
+
* the theme (the rule that structural CSS names no colour holds for the rest
|
|
496
|
+
* of style.ts); they come from the table in languages.ts and never from
|
|
497
|
+
* anything a repository contains.
|
|
498
|
+
*/
|
|
499
|
+
function languagesBlock(languages) {
|
|
500
|
+
if (languages.length === 0)
|
|
501
|
+
return '';
|
|
502
|
+
const pct = (share) => `${share.toFixed(1)}%`;
|
|
503
|
+
const segments = languages.map((l) => (0, html_1.html) `<span class="lang-seg" style="width:${l.share.toFixed(2)}%;background:${l.color}"></span>`);
|
|
504
|
+
const items = languages.map((l) => (0, html_1.html) `<li><span class="lang-dot" style="background:${l.color}"></span><span class="lang-name">${l.name}</span> <span class="lang-pct muted">${pct(l.share)}</span></li>`);
|
|
505
|
+
return (0, html_1.html) `<div class="side-block">
|
|
506
|
+
<h3>Languages</h3>
|
|
507
|
+
<div class="lang-bar" aria-hidden="true">${segments}</div>
|
|
508
|
+
<ul class="lang-list">${items}</ul>
|
|
509
|
+
</div>`;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The About panel beside the repository root: what the repository says it is,
|
|
513
|
+
* and the way in to the documents a reader looks for first.
|
|
514
|
+
*/
|
|
515
|
+
/** The most faces the panel shows before it counts the rest as a number. */
|
|
516
|
+
const SHOWN_CONTRIBUTORS = 12;
|
|
517
|
+
/**
|
|
518
|
+
* Who wrote this repository, by commit count, as GitHub lists in its About
|
|
519
|
+
* panel. Each face leads to that person's commits rather than to a profile
|
|
520
|
+
* page, since a vault has no profiles: the history is what it knows about
|
|
521
|
+
* them.
|
|
522
|
+
*/
|
|
523
|
+
function contributorsBlock(ctx, people) {
|
|
524
|
+
if (people.length === 0)
|
|
525
|
+
return '';
|
|
526
|
+
const base = repoUrl(ctx);
|
|
527
|
+
// A contributor with an account gets the account's identicon, so the same
|
|
528
|
+
// person wears the same face here as they do on the admin pages, whatever
|
|
529
|
+
// git author emails their commits carry.
|
|
530
|
+
const faces = people
|
|
531
|
+
.slice(0, SHOWN_CONTRIBUTORS)
|
|
532
|
+
.map((p) => (0, html_1.html) `<a class="contributor" href="${base}/commits/${encPath(ctx.ref)}?author=${encodeURIComponent(p.email || p.name)}" title="${p.name} - ${count(p.commits)} commit${p.commits === 1 ? '' : 's'}">${(0, avatar_1.avatar)(p.account ?? (p.email || p.name), 28)}</a>`);
|
|
533
|
+
const more = people.length > SHOWN_CONTRIBUTORS
|
|
534
|
+
? (0, html_1.html) `<span class="muted small">+${count(people.length - SHOWN_CONTRIBUTORS)} more</span>`
|
|
535
|
+
: '';
|
|
536
|
+
return (0, html_1.html) `<div class="side-block">
|
|
537
|
+
<h3>Contributors <span class="counter">${count(people.length)}</span></h3>
|
|
538
|
+
<div class="contributors">${faces}${more}</div>
|
|
539
|
+
</div>`;
|
|
540
|
+
}
|
|
541
|
+
function aboutPanel(ctx, view) {
|
|
542
|
+
const base = repoUrl(ctx);
|
|
543
|
+
const blob = (name) => `${base}/blob/${encPath(ctx.ref)}/${encPath(name)}`;
|
|
544
|
+
const license = view.entries.find((e) => e.type === 'blob' && /^(licen[cs]e|copying)(\.[a-z]+)?$/i.test(e.name));
|
|
545
|
+
const links = [];
|
|
546
|
+
if (view.readmeName)
|
|
547
|
+
links.push((0, html_1.html) `<a href="#readme">${(0, icons_1.icon)('book')}<span>Readme</span></a>`);
|
|
548
|
+
if (license)
|
|
549
|
+
links.push((0, html_1.html) `<a href="${blob(license.name)}">${(0, icons_1.icon)('law')}<span>${license.name}</span></a>`);
|
|
550
|
+
if (ctx.hasSite)
|
|
551
|
+
links.push((0, html_1.html) `<a href="${ctx.siteUrl}">${(0, icons_1.icon)('globe')}<span>Site</span></a>`);
|
|
552
|
+
if (ctx.releases.length)
|
|
553
|
+
links.push((0, html_1.html) `<a href="${base}/releases">${(0, icons_1.icon)('rocket')}<span>${count(ctx.releases.length)} release${ctx.releases.length === 1 ? '' : 's'}</span></a>`);
|
|
554
|
+
const settings = ctx.canPush || ctx.canAdmin
|
|
555
|
+
? (0, html_1.html) `<a class="side-edit" href="${base}/settings" title="Edit repository details" aria-label="Edit repository details">${(0, icons_1.icon)('sliders')}</a>`
|
|
556
|
+
: '';
|
|
557
|
+
const description = view.description
|
|
558
|
+
? (0, html_1.html) `<p class="side-desc">${view.description}</p>`
|
|
559
|
+
: (0, html_1.html) `<p class="side-desc muted">No description provided.</p>`;
|
|
560
|
+
const facts = [
|
|
561
|
+
(0, html_1.html) `<a href="${base}/commits/${encPath(ctx.ref)}">${(0, icons_1.icon)('history')}<span>${count(view.commitCount)} commit${view.commitCount === 1 ? '' : 's'}</span></a>`,
|
|
562
|
+
(0, html_1.html) `<a href="${base}/branches">${(0, icons_1.icon)('git-branch')}<span>${count(ctx.branches.length)} branch${ctx.branches.length === 1 ? '' : 'es'}</span></a>`,
|
|
563
|
+
(0, html_1.html) `<a href="${base}/tags">${(0, icons_1.icon)('tag')}<span>${count(ctx.tags.length)} tag${ctx.tags.length === 1 ? '' : 's'}</span></a>`,
|
|
564
|
+
];
|
|
565
|
+
// The documents a reader looks for and the counts they navigate by are both
|
|
566
|
+
// facts about the repository, so they sit in the one About block rather than
|
|
567
|
+
// under a second rule with no caption on it. A hairline keeps them apart.
|
|
568
|
+
return (0, html_1.html) `<aside class="repo-side">
|
|
569
|
+
<div class="side-block">
|
|
570
|
+
<h3>About${settings}</h3>
|
|
571
|
+
${description}
|
|
572
|
+
${links.length ? (0, html_1.html) `<div class="side-links">${links}</div><hr class="rule">` : ''}
|
|
573
|
+
<div class="side-links">${facts}</div>
|
|
574
|
+
</div>
|
|
575
|
+
${contributorsBlock(ctx, view.contributors)}
|
|
576
|
+
${languagesBlock(view.languages)}
|
|
577
|
+
</aside>`;
|
|
578
|
+
}
|
|
579
|
+
function treePage(ctx, view) {
|
|
580
|
+
const base = repoUrl(ctx);
|
|
581
|
+
const { path, entries } = view;
|
|
582
|
+
const refBase = `${base}/tree/${encPath(ctx.ref)}`;
|
|
583
|
+
const atRoot = path === '';
|
|
584
|
+
const rows = [];
|
|
585
|
+
if (!atRoot) {
|
|
586
|
+
const parent = path.split('/').slice(0, -1).join('/');
|
|
587
|
+
const up = parent === '' ? refBase : `${refBase}/${encPath(parent)}`;
|
|
588
|
+
rows.push((0, html_1.html) `<tr><td class="tree-name"><a href="${up}" aria-label="Parent directory">..</a></td><td></td><td></td></tr>`);
|
|
589
|
+
}
|
|
590
|
+
for (const e of entries) {
|
|
591
|
+
const childPath = atRoot ? e.name : `${path}/${e.name}`;
|
|
592
|
+
let name;
|
|
593
|
+
if (e.type === 'tree') {
|
|
594
|
+
name = (0, html_1.html) `${FOLDER_ICON}<a href="${refBase}/${encPath(childPath)}">${e.name}</a>`;
|
|
595
|
+
}
|
|
596
|
+
else if (e.type === 'blob') {
|
|
597
|
+
name = (0, html_1.html) `${FILE_ICON}<a href="${base}/blob/${encPath(ctx.ref)}/${encPath(childPath)}">${e.name}</a>`;
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
name = (0, html_1.html) `${FOLDER_ICON}<span>${e.name}</span> <span class="muted small mono">@ ${e.sha.slice(0, 7)}</span>`;
|
|
601
|
+
}
|
|
602
|
+
// The message and age columns are what a directory listing on GitHub
|
|
603
|
+
// shows, and they answer the question a listing is usually asked: what
|
|
604
|
+
// changed here lately.
|
|
605
|
+
const commit = view.entryCommits.get(childPath);
|
|
606
|
+
const message = commit
|
|
607
|
+
? (0, html_1.html) `<a href="${base}/commit/${commit.sha}" title="${commit.subject}">${commit.subject}</a>`
|
|
608
|
+
: '';
|
|
609
|
+
rows.push((0, html_1.html) `<tr><td class="tree-name">${name}</td><td class="tree-message muted small">${message}</td><td class="tree-age right small">${commit ? (0, render_1.timeTag)(commit.date) : ''}</td></tr>`);
|
|
610
|
+
}
|
|
611
|
+
const latest = view.latest;
|
|
612
|
+
const latestBar = latest
|
|
613
|
+
? (0, html_1.html) `<div class="latest-commit">
|
|
614
|
+
<span class="lc-main"><b>${latest.author}</b> <a href="${base}/commit/${latest.sha}">${latest.subject}</a></span>
|
|
615
|
+
<span class="lc-meta"><a class="sha" href="${base}/commit/${latest.sha}">${latest.sha.slice(0, 7)}</a> ${(0, render_1.timeTag)(latest.date)} <a class="lc-history" href="${base}/commits/${encPath(ctx.ref)}">${(0, icons_1.icon)('history')}<b>${count(view.commitCount)}</b> <span>Commits</span></a></span>
|
|
616
|
+
</div>`
|
|
617
|
+
: '';
|
|
618
|
+
const addFileUrl = `${base}/new/${encPath(ctx.ref)}${atRoot ? '' : `/${encPath(path)}`}`;
|
|
619
|
+
// The history of this directory, which at the root is the history of the
|
|
620
|
+
// repository: the same button GitHub puts above a listing.
|
|
621
|
+
const historyBtn = (0, html_1.html) `<a class="btn" href="${base}/commits/${encPath(ctx.ref)}${atRoot ? '' : `/${encPath(path)}`}" title="Commits touching this directory">${(0, icons_1.icon)('history')}<span>History</span></a>`;
|
|
622
|
+
// Forking is offered to anyone who can sign in; where the copy may go is
|
|
623
|
+
// settled on the form, where the answer can be explained.
|
|
624
|
+
const forkBtn = atRoot
|
|
625
|
+
? (0, html_1.html) `<a class="btn" href="${ctx.viewer ? `${base}/fork` : `/login?next=${encodeURIComponent(`${base}/fork`)}`}" title="Copy this repository elsewhere in the vault">${(0, icons_1.icon)('repo-forked')}<span>Fork</span></a>`
|
|
626
|
+
: '';
|
|
627
|
+
// GitHub's "Add file" is a menu of two: write one here, or upload some.
|
|
628
|
+
const uploadUrl = `${base}/upload/${encPath(ctx.ref)}${atRoot ? '' : `/${encPath(path)}`}`;
|
|
629
|
+
const addFileBtn = ctx.canPush && ctx.refIsBranch
|
|
630
|
+
? (0, html_1.html) `<details class="dropdown">
|
|
631
|
+
<summary class="btn">${(0, icons_1.icon)('plus')}<span>Add file</span>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
|
|
632
|
+
<div class="dropdown-menu dd-right">
|
|
633
|
+
<a class="dd-item" href="${addFileUrl}">${(0, icons_1.icon)('file')}<span class="dd-label">Create new file</span></a>
|
|
634
|
+
<a class="dd-item" href="${uploadUrl}">${(0, icons_1.icon)('upload')}<span class="dd-label">Upload files</span></a>
|
|
635
|
+
</div>
|
|
636
|
+
</details>`
|
|
637
|
+
: '';
|
|
638
|
+
const readmePath = atRoot ? view.readmeName : `${path}/${view.readmeName}`;
|
|
639
|
+
const readme = view.readmeHtml
|
|
640
|
+
? (0, html_1.html) `<div class="box" id="readme"><div class="box-header">${(0, icons_1.icon)('book')}<a href="${base}/blob/${encPath(ctx.ref)}/${encPath(readmePath ?? 'README')}">${view.readmeName ?? 'README'}</a></div><div class="box-body markdown-body">${(0, html_1.raw)(view.readmeHtml)}</div></div>`
|
|
641
|
+
: '';
|
|
642
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
643
|
+
<div class="toolbar">
|
|
644
|
+
<div class="left">${refPicker(ctx, (ref) => `${base}/tree/${encPath(ref)}`)}${breadcrumb(ctx, path)}</div>
|
|
645
|
+
<div class="right-group">${findButton(ctx)}${historyBtn}${forkBtn}${addFileBtn}${cloneMenu(ctx)}</div>
|
|
646
|
+
</div>
|
|
647
|
+
<div class="repo-layout">
|
|
648
|
+
<div class="repo-main">
|
|
649
|
+
${latestBar}
|
|
650
|
+
<table class="listing tree"><tbody>${rows}</tbody></table>
|
|
651
|
+
${readme}
|
|
652
|
+
</div>
|
|
653
|
+
${atRoot ? aboutPanel(ctx, view) : ''}
|
|
654
|
+
</div>`;
|
|
655
|
+
return layout(`${ctx.collection}/${ctx.repo}${path ? ` at ${path}` : ''}`, content, repoOpts(ctx, atRoot ? repoUrl(ctx) : `${refBase}/${encPath(path)}`));
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Show one matching line with the query marked in it. The positions come from
|
|
659
|
+
* the raw text, so the slices around them are escaped individually rather
|
|
660
|
+
* than searching escaped HTML for something that may no longer look the same.
|
|
661
|
+
* A long line is cut around its first match: a result list is for finding the
|
|
662
|
+
* file, not for reading it.
|
|
663
|
+
*/
|
|
664
|
+
function markMatches(text, query) {
|
|
665
|
+
const hay = text.toLowerCase();
|
|
666
|
+
const needle = query.toLowerCase();
|
|
667
|
+
let from = needle === '' ? -1 : hay.indexOf(needle);
|
|
668
|
+
const first = from;
|
|
669
|
+
const WINDOW = 240;
|
|
670
|
+
let start = 0;
|
|
671
|
+
let cut = text;
|
|
672
|
+
if (text.length > WINDOW && first > WINDOW / 2) {
|
|
673
|
+
start = first - Math.floor(WINDOW / 3);
|
|
674
|
+
cut = text.slice(start);
|
|
675
|
+
}
|
|
676
|
+
if (cut.length > WINDOW)
|
|
677
|
+
cut = cut.slice(0, WINDOW);
|
|
678
|
+
const body = cut.toLowerCase();
|
|
679
|
+
let out = '';
|
|
680
|
+
let at = 0;
|
|
681
|
+
for (let i = needle === '' ? -1 : body.indexOf(needle); i !== -1; i = body.indexOf(needle, at)) {
|
|
682
|
+
out += (0, render_1.esc)(cut.slice(at, i)) + `<mark>${(0, render_1.esc)(cut.slice(i, i + needle.length))}</mark>`;
|
|
683
|
+
at = i + needle.length;
|
|
684
|
+
}
|
|
685
|
+
out += (0, render_1.esc)(cut.slice(at));
|
|
686
|
+
// Escaped by hand above, around positions found in the raw text; the whole
|
|
687
|
+
// is therefore already HTML.
|
|
688
|
+
return (0, html_1.raw)(`${start > 0 ? '…' : ''}${out}${start + cut.length < text.length ? '…' : ''}`);
|
|
689
|
+
}
|
|
690
|
+
/** Search results: the matching lines, grouped by the file they are in. */
|
|
691
|
+
function searchPage(ctx, query, view) {
|
|
692
|
+
const base = repoUrl(ctx);
|
|
693
|
+
const searchUrl = (ref) => `${base}/search?q=${encodeURIComponent(query)}&ref=${encodeURIComponent(ref)}`;
|
|
694
|
+
const form = (0, html_1.html) `<form class="search-form" method="get" action="${base}/search" role="search"><input type="hidden" name="ref" value="${ctx.ref}"><input class="search-input" type="search" name="q" value="${query}" placeholder="Search this repository" aria-label="Search this repository" autofocus>${(0, icons_1.icon)('search', 'search-glyph')}</form>`;
|
|
695
|
+
const boxes = view.files.map((f) => {
|
|
696
|
+
const fileUrl = `${base}/blob/${encPath(ctx.ref)}/${encPath(f.path)}`;
|
|
697
|
+
const lines = f.hits.map((h) => (0, html_1.html) `<a class="search-hit" href="${fileUrl}#L${h.line}"><span class="lnum">${h.line}</span><span class="ltext">${markMatches(h.text, query)}</span></a>`);
|
|
698
|
+
const more = f.more
|
|
699
|
+
? (0, html_1.html) `<a class="search-more" href="${fileUrl}">${count(f.more)} more match${f.more === 1 ? '' : 'es'} in this file</a>`
|
|
700
|
+
: '';
|
|
701
|
+
return (0, html_1.html) `<div class="box search-file"><div class="box-header">${FILE_ICON}<a href="${fileUrl}">${f.path}</a></div><div class="search-hits">${lines}${more}</div></div>`;
|
|
702
|
+
});
|
|
703
|
+
const notes = [];
|
|
704
|
+
if (view.truncated)
|
|
705
|
+
notes.push('There were more matches than this page can show.');
|
|
706
|
+
if (view.capped)
|
|
707
|
+
notes.push('Matches in further files were left out.');
|
|
708
|
+
let body;
|
|
709
|
+
if (query.trim() === '') {
|
|
710
|
+
body = (0, html_1.html) `<div class="empty-state">Type to search the files at ${ctx.ref}. Matching is literal text, not a pattern.</div>`;
|
|
711
|
+
}
|
|
712
|
+
else if (view.files.length === 0) {
|
|
713
|
+
body = (0, html_1.html) `<div class="empty-state">No file at ${ctx.ref} contains ${query}.</div>`;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
body = (0, html_1.html) `<p class="muted small">${count(view.total)} matching line${view.total === 1 ? '' : 's'} in ${count(view.files.length)} file${view.files.length === 1 ? '' : 's'}${notes.length ? (0, html_1.html) ` · ${notes.join(' ')}` : ''}</p>${boxes}`;
|
|
717
|
+
}
|
|
718
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
719
|
+
<div class="toolbar">
|
|
720
|
+
<div class="left">${refPicker(ctx, searchUrl)}${form}</div>
|
|
721
|
+
<div class="right-group">${findButton(ctx)}</div>
|
|
722
|
+
</div>
|
|
723
|
+
${body}`;
|
|
724
|
+
return layout(`${query ? `Search: ${query}` : 'Search'} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/search`));
|
|
725
|
+
}
|
|
726
|
+
function findFilePage(ctx, paths, total) {
|
|
727
|
+
const base = repoUrl(ctx);
|
|
728
|
+
const items = paths.map((p) => {
|
|
729
|
+
const cut = p.lastIndexOf('/');
|
|
730
|
+
const dir = cut === -1 ? '' : (0, html_1.html) `<span class="muted">${p.slice(0, cut + 1)}</span>`;
|
|
731
|
+
return (0, html_1.html) `<a class="find-item" href="${base}/blob/${encPath(ctx.ref)}/${encPath(p)}">${FILE_ICON}<span>${dir}${p.slice(cut + 1)}</span></a>`;
|
|
732
|
+
});
|
|
733
|
+
const capped = total > paths.length
|
|
734
|
+
? (0, html_1.html) `<p class="muted small">Showing the first ${count(paths.length)} of ${count(total)} files.</p>`
|
|
735
|
+
: '';
|
|
736
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
737
|
+
<div class="toolbar">
|
|
738
|
+
<div class="left">${refPicker(ctx, (ref) => `${base}/find/${encPath(ref)}`)}<span class="muted small">${count(total)} file${total === 1 ? '' : 's'}</span></div>
|
|
739
|
+
</div>
|
|
740
|
+
<input class="find-input" type="text" placeholder="Go to file" autofocus autocomplete="off" spellcheck="false" aria-label="Go to file">
|
|
741
|
+
${capped}
|
|
742
|
+
<div class="find-list" id="find-list">${items}</div>
|
|
743
|
+
<div class="empty-state" id="find-empty" hidden>No file matches.</div>`;
|
|
744
|
+
return layout(`Find a file - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/find/${encPath(ctx.ref)}`));
|
|
745
|
+
}
|
|
746
|
+
function blobPage(ctx, path, view, isMarkdown = false) {
|
|
747
|
+
const base = repoUrl(ctx);
|
|
748
|
+
const blobUrl = `${base}/blob/${encPath(ctx.ref)}/${encPath(path)}`;
|
|
749
|
+
const rawUrl = `${base}/raw/${encPath(ctx.ref)}/${encPath(path)}`;
|
|
750
|
+
const editable = (view.kind === 'code' || view.kind === 'markdown') && view.editable;
|
|
751
|
+
const editBtns = editable
|
|
752
|
+
? (0, html_1.html) `<a class="btn" href="${base}/edit/${encPath(ctx.ref)}/${encPath(path)}" title="Edit this file">${(0, icons_1.icon)('pencil')}<span>Edit</span></a><a class="btn btn-danger-outline" href="${base}/delete/${encPath(ctx.ref)}/${encPath(path)}" title="Delete this file">${(0, icons_1.icon)('trash')}<span>Delete</span></a>`
|
|
753
|
+
: '';
|
|
754
|
+
// GitHub spells the source view of a rendered file ?plain=1; we follow that.
|
|
755
|
+
const seg = (label, glyph, href, current) => (0, html_1.html) `<a${current ? (0, html_1.raw)(' class="current"') : ''} href="${href}">${(0, icons_1.icon)(glyph)}<span>${label}</span></a>`;
|
|
756
|
+
const toggle = isMarkdown
|
|
757
|
+
? (0, html_1.html) `<span class="seg">${seg('Preview', 'book', blobUrl, view.kind === 'markdown')}${seg('Code', 'code', `${blobUrl}?plain=1`, view.kind !== 'markdown')}</span>`
|
|
758
|
+
: '';
|
|
759
|
+
let body;
|
|
760
|
+
const historyBtn = (0, html_1.html) `<a class="btn" href="${base}/commits/${encPath(ctx.ref)}/${encPath(path)}" title="Commits touching this file">${(0, icons_1.icon)('history')}<span>History</span></a>`;
|
|
761
|
+
// Blame is for anything we render as text, which includes a markdown file
|
|
762
|
+
// being shown as a document rather than as source.
|
|
763
|
+
const blameBtn = view.kind === 'code' || view.kind === 'markdown'
|
|
764
|
+
? (0, html_1.html) `<a class="btn" href="${base}/blame/${encPath(ctx.ref)}/${encPath(path)}" title="Who last changed each line">${(0, icons_1.icon)('versions')}<span>Blame</span></a>`
|
|
765
|
+
: '';
|
|
766
|
+
const meta = (left, extra = '') => (0, html_1.html) `<div class="code-meta"><span class="muted small">${left}</span><span class="right-group">${toggle}${extra}${blameBtn}${historyBtn}<a class="btn" href="${rawUrl}" title="View the file as it was committed">${(0, icons_1.icon)('download')}<span>Raw</span></a>${editBtns}</span></div>`;
|
|
767
|
+
if (view.kind === 'code') {
|
|
768
|
+
// One element per line, each an anchor: linking to a line is how people
|
|
769
|
+
// point at code, and #L12 is the address GitHub taught them to expect.
|
|
770
|
+
// The line text is highlight.js output, already escaped by its renderer.
|
|
771
|
+
const rows = (0, render_1.highlightedLines)(view.html).map((line, i) => {
|
|
772
|
+
const n = i + 1;
|
|
773
|
+
return (0, html_1.html) `<div class="cline" id="L${n}"><a class="lnum" href="#L${n}" aria-label="Line ${n}">${n}</a><span class="ltext">${(0, html_1.raw)(line)}</span></div>`;
|
|
774
|
+
});
|
|
775
|
+
const copyRaw = (0, html_1.html) `<button class="btn" type="button" data-copy-lines title="Copy the file's contents"><span class="copy-idle">${(0, icons_1.icon)('copy')}<span>Copy</span></span><span class="copy-done">${(0, icons_1.icon)('check')}<span>Copied</span></span></button>`;
|
|
776
|
+
body = (0, html_1.html) `${meta((0, html_1.html) `${view.lineCount} line${view.lineCount === 1 ? '' : 's'} · ${(0, render_1.formatSize)(view.size)}`, copyRaw)}
|
|
777
|
+
<div class="code-lines">${rows}</div>`;
|
|
778
|
+
}
|
|
779
|
+
else if (view.kind === 'markdown') {
|
|
780
|
+
body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}
|
|
781
|
+
<div class="rendered markdown-body">${(0, html_1.raw)(view.html)}</div>`;
|
|
782
|
+
}
|
|
783
|
+
else if (view.kind === 'image') {
|
|
784
|
+
body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}<div class="blob-image"><img src="${rawUrl}" alt="${path}"></div>`;
|
|
785
|
+
}
|
|
786
|
+
else if (view.kind === 'too-large') {
|
|
787
|
+
body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}<div class="blob-binary">File is too large to display. <a href="${rawUrl}">View raw</a></div>`;
|
|
788
|
+
}
|
|
789
|
+
else if (view.kind === 'lfs') {
|
|
790
|
+
// The size comes from the pointer, so no storage request is needed to
|
|
791
|
+
// render this card.
|
|
792
|
+
body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}<div class="blob-binary">
|
|
793
|
+
<p><b>Stored with Git LFS</b></p>
|
|
794
|
+
<p>This file is ${(0, render_1.formatSize)(view.size)}; the repository holds a pointer to it.</p>
|
|
795
|
+
<p class="muted small mono">sha256:${view.oid}</p>
|
|
796
|
+
<p><a class="btn btn-primary" href="${rawUrl}">${(0, icons_1.icon)('download')}<span>Download</span></a></p>
|
|
797
|
+
</div>`;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}<div class="blob-binary">Binary file. <a href="${rawUrl}">View raw</a></div>`;
|
|
801
|
+
}
|
|
802
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
803
|
+
<div class="toolbar">
|
|
804
|
+
<div class="left">${refPicker(ctx, (ref) => `${base}/blob/${encPath(ref)}/${encPath(path)}`)}${breadcrumb(ctx, path)}</div>
|
|
805
|
+
</div>
|
|
806
|
+
${body}`;
|
|
807
|
+
return layout(`${path} at ${ctx.ref} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, blobUrl));
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* The blame view: every line of a file beside the commit that last touched
|
|
811
|
+
* it, with consecutive lines from the same commit forming a block, as on
|
|
812
|
+
* GitHub. A block that has a previous revision also offers the blame as it
|
|
813
|
+
* stood before that change, which is how a reader walks a line backwards
|
|
814
|
+
* through history.
|
|
815
|
+
*/
|
|
816
|
+
function blamePage(ctx, path, highlighted, lines, size) {
|
|
817
|
+
const base = repoUrl(ctx);
|
|
818
|
+
const blobUrl = `${base}/blob/${encPath(ctx.ref)}/${encPath(path)}`;
|
|
819
|
+
const texts = (0, render_1.highlightedLines)(highlighted);
|
|
820
|
+
const rows = lines.map((l, i) => {
|
|
821
|
+
const n = i + 1;
|
|
822
|
+
const starts = i === 0 || lines[i - 1].sha !== l.sha;
|
|
823
|
+
const prior = starts && l.previous
|
|
824
|
+
? (0, html_1.html) `<a class="blame-prior" href="${base}/blame/${encPath(l.previous.sha)}/${encPath(l.previous.path)}" title="Blame this file before this change" aria-label="Blame this file before this change">${(0, icons_1.icon)('versions')}</a>`
|
|
825
|
+
: '';
|
|
826
|
+
const about = starts
|
|
827
|
+
? (0, html_1.html) `<a class="sha" href="${base}/commit/${l.sha}">${l.sha.slice(0, 7)}</a><a class="blame-subject" href="${base}/commit/${l.sha}" title="${l.summary}">${l.summary}</a><span class="blame-when small muted">${l.author} ${(0, render_1.timeTag)(l.date)}</span>${prior}`
|
|
828
|
+
: '';
|
|
829
|
+
return (0, html_1.html) `<div class="blame-row${starts ? ' blame-start' : ''}" id="L${n}"><span class="blame-commit">${about}</span><a class="lnum" href="#L${n}" aria-label="Line ${n}">${n}</a><span class="ltext">${(0, html_1.raw)(texts[i] ?? '')}</span></div>`;
|
|
830
|
+
});
|
|
831
|
+
const toggle = (0, html_1.html) `<span class="seg"><a href="${blobUrl}">Code</a><a class="current" href="${base}/blame/${encPath(ctx.ref)}/${encPath(path)}">Blame</a></span>`;
|
|
832
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
833
|
+
<div class="toolbar">
|
|
834
|
+
<div class="left">${refPicker(ctx, (ref) => `${base}/blame/${encPath(ref)}/${encPath(path)}`)}${breadcrumb(ctx, path)}</div>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="code-meta"><span class="muted small">${lines.length} line${lines.length === 1 ? '' : 's'} · ${(0, render_1.formatSize)(size)}</span><span class="right-group">${toggle}<a class="btn" href="${base}/commits/${encPath(ctx.ref)}/${encPath(path)}" title="Commits touching this file">${(0, icons_1.icon)('history')}<span>History</span></a></span></div>
|
|
837
|
+
<div class="blame">${rows}</div>`;
|
|
838
|
+
return layout(`Blame ${path} at ${ctx.ref} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/blame/${encPath(ctx.ref)}/${encPath(path)}`));
|
|
839
|
+
}
|
|
840
|
+
function commitsPage(ctx, path, commits, page, totalPages, totalCount, author) {
|
|
841
|
+
const base = repoUrl(ctx);
|
|
842
|
+
const suffix = path === '' ? '' : `/${encPath(path)}`;
|
|
843
|
+
const query = author ? `?author=${encodeURIComponent(author)}` : '';
|
|
844
|
+
// Each row carries what a reader might want next from that commit: to read
|
|
845
|
+
// it, to take its id, or to browse the tree as it stood then.
|
|
846
|
+
const row = (c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main">${(0, avatar_1.avatar)(c.author, 20)}<span><a class="title" href="${base}/commit/${c.sha}">${c.subject}</a><div class="muted small">${c.author} committed ${(0, render_1.timeTag)(c.date)}</div></span></span><span class="commit-actions"><a class="sha" href="${base}/commit/${c.sha}">${c.sha.slice(0, 7)}</a>${copyButton('', c.sha, 'Copy the full commit id')}<a class="btn" href="${base}/tree/${c.sha}" title="Browse the repository at this commit" aria-label="Browse the repository at this commit">${(0, icons_1.icon)('code')}</a></span></div>`;
|
|
847
|
+
// Commits are grouped under the day they landed, as on GitHub: a history
|
|
848
|
+
// reads as a sequence of days, and the dates stop repeating on every row.
|
|
849
|
+
const groups = [];
|
|
850
|
+
for (const c of commits) {
|
|
851
|
+
const day = (0, render_1.formatDay)(c.date);
|
|
852
|
+
const last = groups[groups.length - 1];
|
|
853
|
+
if (last && last.day === day)
|
|
854
|
+
last.rows.push(row(c));
|
|
855
|
+
else
|
|
856
|
+
groups.push({ day, rows: [row(c)] });
|
|
857
|
+
}
|
|
858
|
+
const rows = groups.map((g) => (0, html_1.html) `<div class="commit-day">${(0, icons_1.icon)('git-commit')}<span>Commits on ${g.day}</span></div><div class="commit-group">${g.rows}</div>`);
|
|
859
|
+
const pager = [];
|
|
860
|
+
const pageUrl = (p) => `${base}/commits/${encPath(ctx.ref)}${suffix}?page=${p}${author ? `&author=${encodeURIComponent(author)}` : ''}`;
|
|
861
|
+
if (page > 1)
|
|
862
|
+
pager.push((0, html_1.html) `<a class="btn" href="${(0, html_1.raw)(pageUrl(page - 1))}">← Newer</a>`);
|
|
863
|
+
if (page < totalPages)
|
|
864
|
+
pager.push((0, html_1.html) `<a class="btn" href="${(0, html_1.raw)(pageUrl(page + 1))}">Older →</a>`);
|
|
865
|
+
const scope = path === ''
|
|
866
|
+
? (0, html_1.html) `<span class="muted small">${count(totalCount)} commit${totalCount === 1 ? '' : 's'}</span>`
|
|
867
|
+
: (0, html_1.html) `${breadcrumb(ctx, path)}<span class="muted small">${count(totalCount)} commit${totalCount === 1 ? '' : 's'} touching this path</span>`;
|
|
868
|
+
const empty = author
|
|
869
|
+
? (0, html_1.html) `No commits here are by ${author}.`
|
|
870
|
+
: path === ''
|
|
871
|
+
? (0, html_1.html) `No commits on this ref.`
|
|
872
|
+
: (0, html_1.html) `Nothing in this ref's history touches ${path}.`;
|
|
873
|
+
// A filter the reader can see is a filter they can take off again.
|
|
874
|
+
const byAuthor = author
|
|
875
|
+
? (0, html_1.html) `<span class="filter-chip">${(0, icons_1.icon)('person')}<span>${author}</span><a href="${base}/commits/${encPath(ctx.ref)}${suffix}" title="Show every author" aria-label="Show every author">${(0, icons_1.icon)('x')}</a></span>`
|
|
876
|
+
: '';
|
|
877
|
+
const feed = (0, html_1.html) `<a class="btn" href="${base}/commits/${encPath(ctx.ref)}${suffix}.atom" title="Atom feed of this history">${(0, icons_1.icon)('rss')}<span>Feed</span></a>`;
|
|
878
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'commits')}
|
|
879
|
+
<div class="toolbar"><div class="left">${refPicker(ctx, (ref) => `${base}/commits/${encPath(ref)}${suffix}${query}`)}${byAuthor}${scope}</div><div class="right-group">${feed}</div></div>
|
|
880
|
+
${rows.length ? rows : (0, html_1.html) `<div class="empty-state">${empty}</div>`}
|
|
881
|
+
${pager.length ? (0, html_1.html) `<div class="pagination">${pager}</div>` : ''}`;
|
|
882
|
+
return layout(`Commits${path ? ` for ${path}` : ''} at ${ctx.ref} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/commits/${encPath(ctx.ref)}${suffix}`));
|
|
883
|
+
}
|
|
884
|
+
function commitPage(ctx, detail, diffHtml) {
|
|
885
|
+
const base = repoUrl(ctx);
|
|
886
|
+
const lines = detail.message.split('\n');
|
|
887
|
+
const subject = lines[0] ?? '';
|
|
888
|
+
const body = lines.slice(1).join('\n').trim();
|
|
889
|
+
const parents = (0, html_1.joinHtml)(detail.parents.map((p) => (0, html_1.html) `<a class="sha" href="${base}/commit/${p}">${p.slice(0, 7)}</a>`), ' ');
|
|
890
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'commits')}
|
|
891
|
+
<div class="commit-head">
|
|
892
|
+
<div class="subject">${subject}</div>
|
|
893
|
+
${body ? (0, html_1.html) `<div class="body">${body}</div>` : ''}
|
|
894
|
+
<div class="meta">
|
|
895
|
+
<span><b>${detail.author}</b> <${detail.email}></span>
|
|
896
|
+
<span>committed ${(0, render_1.timeTag)(detail.date, '')}</span>
|
|
897
|
+
<span>commit <span class="sha">${detail.sha.slice(0, 12)}</span></span>
|
|
898
|
+
${detail.parents.length ? (0, html_1.html) `<span>parent${detail.parents.length > 1 ? 's' : ''} ${parents}</span>` : ''}
|
|
899
|
+
<span><a href="${base}/tree/${detail.sha}">Browse files</a></span>
|
|
900
|
+
</div>
|
|
901
|
+
</div>
|
|
902
|
+
${(0, html_1.raw)(diffHtml)}`;
|
|
903
|
+
return layout(`${subject} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/commit/${detail.sha}`));
|
|
904
|
+
}
|
|
905
|
+
function refListPage(ctx, kind) {
|
|
906
|
+
const base = repoUrl(ctx);
|
|
907
|
+
const refs = kind === 'branches' ? ctx.branches : ctx.tags;
|
|
908
|
+
const viewer = ctx.viewer;
|
|
909
|
+
const noun = kind === 'branches' ? 'branch' : 'tag';
|
|
910
|
+
const listId = `${kind}-list`;
|
|
911
|
+
const rows = refs.map((r) => {
|
|
912
|
+
let action = '';
|
|
913
|
+
if (ctx.canPush && viewer && (kind === 'tags' || r.name !== ctx.defaultBranch)) {
|
|
914
|
+
action = (0, html_1.html) `<form method="post" action="${base}/${kind}/delete" data-confirm="Delete ${noun} ${r.name}?">${csrfField(viewer)}<input type="hidden" name="name" value="${r.name}"><button type="submit" class="btn btn-danger-outline" title="Delete this ${noun}" aria-label="Delete ${r.name}">${(0, icons_1.icon)('trash')}</button></form>`;
|
|
915
|
+
}
|
|
916
|
+
// A tag is what people download a release from, so its row carries the
|
|
917
|
+
// archives, as the tags page on GitHub does.
|
|
918
|
+
const archives = kind === 'tags'
|
|
919
|
+
? (0, html_1.html) `<a class="btn" href="${base}/archive/${encPath(r.name)}.zip" title="Download this tag as a zip">${(0, icons_1.icon)('file-zip')}<span>zip</span></a><a class="btn" href="${base}/archive/${encPath(r.name)}.tar.gz" title="Download this tag as a tar.gz">${(0, icons_1.icon)('file-zip')}<span>tar.gz</span></a>`
|
|
920
|
+
: '';
|
|
921
|
+
const badge = kind === 'branches' && r.name === ctx.defaultBranch ? (0, html_1.html) ` <span class="badge">Default</span>` : '';
|
|
922
|
+
// A tag with notes leads to them; one without invites whoever may push
|
|
923
|
+
// to write them, which is the only way a release is ever created.
|
|
924
|
+
const release = kind === 'tags'
|
|
925
|
+
? ctx.releases.includes(r.name)
|
|
926
|
+
? (0, html_1.html) `<a class="btn" href="${base}/releases/tag/${encPath(r.name)}" title="Release notes for ${r.name}">${(0, icons_1.icon)('rocket')}<span>Release</span></a>`
|
|
927
|
+
: ctx.canPush
|
|
928
|
+
? (0, html_1.html) `<a class="btn" href="${base}/releases/new?tag=${encodeURIComponent(r.name)}" title="Write release notes for ${r.name}">${(0, icons_1.icon)('rocket')}<span>Draft release</span></a>`
|
|
929
|
+
: ''
|
|
930
|
+
: '';
|
|
931
|
+
// Every ref but the default one can be compared against it, which is
|
|
932
|
+
// the question a list of branches invites: what is on this one?
|
|
933
|
+
const compare = ctx.defaultBranch && r.name !== ctx.defaultBranch
|
|
934
|
+
? (0, html_1.html) `<a class="btn" href="${base}/compare/${encPath(ctx.defaultBranch)}...${encPath(r.name)}" title="Compare with ${ctx.defaultBranch}">${(0, icons_1.icon)('git-compare')}<span>Compare</span></a>`
|
|
935
|
+
: '';
|
|
936
|
+
return (0, html_1.html) `<tr>
|
|
937
|
+
<td class="ref-name">${(0, icons_1.icon)(kind === 'branches' ? 'git-branch' : 'tag', 'icon')}<a href="${base}/tree/${encPath(r.name)}"><b>${r.name}</b></a>${badge}
|
|
938
|
+
<div class="muted small">Updated ${(0, render_1.timeTag)(r.date)} · ${r.subject}</div></td>
|
|
939
|
+
<td class="right"><a class="sha" href="${base}/commit/${r.sha}">${r.sha.slice(0, 7)}</a></td>
|
|
940
|
+
<td class="right"><span class="right-group">${release}${compare}${archives}${action}</span></td>
|
|
941
|
+
</tr>`;
|
|
942
|
+
});
|
|
943
|
+
const body = rows.length
|
|
944
|
+
? (0, html_1.html) `${listFilter(listId, `Find a ${noun}`, refs.length)}<table class="listing" id="${listId}"><tbody>${rows}</tbody></table>${noMatches(listId)}`
|
|
945
|
+
: (0, html_1.html) `<div class="empty-state">No ${kind} yet.</div>`;
|
|
946
|
+
let createMenu = '';
|
|
947
|
+
if (ctx.canPush && viewer && ctx.branches.length > 0) {
|
|
948
|
+
const fromOptions = ctx.branches.map((b) => (0, html_1.html) `<option value="${b.name}"${b.name === ctx.defaultBranch ? (0, html_1.raw)(' selected') : ''}>${b.name}</option>`);
|
|
949
|
+
const form = kind === 'branches'
|
|
950
|
+
? (0, html_1.html) `<form method="post" action="${base}/branches/create">${csrfField(viewer)}
|
|
951
|
+
<div class="field"><label for="new-ref">New branch name</label><input type="text" id="new-ref" name="name" placeholder="new-branch-name" required></div>
|
|
952
|
+
<div class="field"><label for="ref-from">From</label><select id="ref-from" name="from">${fromOptions}</select></div>
|
|
953
|
+
<button type="submit" class="btn btn-primary">Create branch</button></form>`
|
|
954
|
+
: (0, html_1.html) `<form method="post" action="${base}/tags/create">${csrfField(viewer)}
|
|
955
|
+
<div class="field"><label for="new-ref">Tag name</label><input type="text" id="new-ref" name="name" placeholder="v1.0.0" required></div>
|
|
956
|
+
<div class="field"><label for="ref-at">At</label><select id="ref-at" name="at">${fromOptions}</select></div>
|
|
957
|
+
<button type="submit" class="btn btn-primary">Create tag</button></form>`;
|
|
958
|
+
createMenu = (0, html_1.html) `<details class="dropdown">
|
|
959
|
+
<summary class="btn btn-primary">${(0, icons_1.icon)('plus')}<span>New ${noun}</span>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
|
|
960
|
+
<div class="dropdown-menu dd-right ref-form">${form}</div>
|
|
961
|
+
</details>`;
|
|
962
|
+
}
|
|
963
|
+
const content = (0, html_1.html) `${repoHeader(ctx, kind)}<div class="page-head"><h2>${kind === 'branches' ? 'Branches' : 'Tags'}</h2>${createMenu}</div>${body}`;
|
|
964
|
+
return layout(`${kind} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, `${base}/${kind}`));
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* The quick-setup page GitHub shows for a repository with no commits: the
|
|
968
|
+
* address to push to, and the two command sequences that are the usual next
|
|
969
|
+
* step. It is the one place in the interface that teaches git commands,
|
|
970
|
+
* because it is the one place where the answer really is a command.
|
|
971
|
+
*/
|
|
972
|
+
function emptyRepoPage(ctx) {
|
|
973
|
+
const base = repoUrl(ctx);
|
|
974
|
+
const url = ctx.cloneUrl;
|
|
975
|
+
const block = (lines) => (0, html_1.html) `<div class="cmd-block"><pre>${lines.join('\n')}</pre>${copyButton()}</div>`;
|
|
976
|
+
const readmeBtn = ctx.canPush
|
|
977
|
+
? (0, html_1.html) `<a class="btn btn-primary" href="${base}/new/main">${(0, icons_1.icon)('plus')}<span>Create a README</span></a>`
|
|
978
|
+
: '';
|
|
979
|
+
const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
|
|
980
|
+
<div class="box">
|
|
981
|
+
<div class="box-header">${(0, icons_1.icon)('repo')}Quick setup, if you have done this before</div>
|
|
982
|
+
<div class="box-body">
|
|
983
|
+
<div class="cmd-row"><code>${url}</code>${copyButton()}</div>
|
|
984
|
+
<p class="muted">Cloning is anonymous. Pushing asks for your username and a token; <span class="mono">mochi login</span> hands the token to git once so it stops asking.</p>
|
|
985
|
+
${readmeBtn}
|
|
986
|
+
</div>
|
|
987
|
+
</div>
|
|
988
|
+
<h3 class="setup-head">…or create a new repository on the command line</h3>
|
|
989
|
+
${block([
|
|
990
|
+
`echo "# ${ctx.repo}" >> README.md`,
|
|
991
|
+
'git init',
|
|
992
|
+
'git add README.md',
|
|
993
|
+
'git commit -m "first commit"',
|
|
994
|
+
'git branch -M main',
|
|
995
|
+
`git remote add origin ${url}`,
|
|
996
|
+
'git push -u origin main',
|
|
997
|
+
])}
|
|
998
|
+
<h3 class="setup-head">…or push an existing repository from the command line</h3>
|
|
999
|
+
${block([`git remote add origin ${url}`, 'git branch -M main', 'git push -u origin main'])}`;
|
|
1000
|
+
return layout(`${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, base));
|
|
1001
|
+
}
|
|
1002
|
+
function errorPage(status, message, opts = {}) {
|
|
1003
|
+
const back = opts.backUrl
|
|
1004
|
+
? (0, html_1.html) `<p><a href="${opts.backUrl}">Go back</a></p>`
|
|
1005
|
+
: (0, html_1.html) `<p><a href="/">Back to home</a></p>`;
|
|
1006
|
+
return layout(`${status}`, (0, html_1.html) `<div class="error-page"><div class="code">${status}</div><p>${message}</p>${back}</div>`, opts);
|
|
1007
|
+
}
|