diff --git a/main.go b/main.go index 070e9857d08c0..4b183a768609d 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" // register supported doc types + _ "code.gitea.io/gitea/modules/markup/asciicast" _ "code.gitea.io/gitea/modules/markup/console" _ "code.gitea.io/gitea/modules/markup/csv" _ "code.gitea.io/gitea/modules/markup/markdown" diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go new file mode 100644 index 0000000000000..06780623403a4 --- /dev/null +++ b/modules/markup/asciicast/asciicast.go @@ -0,0 +1,64 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asciicast + +import ( + "fmt" + "io" + "net/url" + "regexp" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" +) + +func init() { + markup.RegisterRenderer(Renderer{}) +} + +// Renderer implements markup.Renderer for asciicast files. +// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md +type Renderer struct{} + +// Name implements markup.Renderer +func (Renderer) Name() string { + return "asciicast" +} + +// Extensions implements markup.Renderer +func (Renderer) Extensions() []string { + return []string{".cast"} +} + +const ( + playerClassName = "asciinema-player-container" + playerSrcAttr = "data-asciinema-player-src" +) + +// SanitizerRules implements markup.Renderer +func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { + return []setting.MarkupSanitizerRule{ + {Element: "div", AllowAttr: "class", Regexp: regexp.MustCompile(playerClassName)}, + {Element: "div", AllowAttr: playerSrcAttr}, + } +} + +// Render implements markup.Renderer +func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error { + rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s", + setting.AppSubURL, + url.PathEscape(ctx.Metas["user"]), + url.PathEscape(ctx.Metas["repo"]), + ctx.Metas["BranchNameSubURL"], + url.PathEscape(ctx.RelativePath), + ) + + _, err := io.WriteString(output, fmt.Sprintf( + `
`, + playerClassName, + playerSrcAttr, + rawURL, + )) + return err +} diff --git a/package-lock.json b/package-lock.json index a301dcddb301e..af317e1721cde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@primer/octicons": "17.10.0", "@vue/compiler-sfc": "3.2.45", "add-asset-webpack-plugin": "2.0.1", + "asciinema-player": "3.0.1", "css-loader": "6.7.3", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", @@ -197,6 +198,17 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@braintree/sanitize-url": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", @@ -2094,6 +2106,15 @@ "printable-characters": "^1.0.42" } }, + "node_modules/asciinema-player": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.0.1.tgz", + "integrity": "sha512-plm/C/MhOtZWysrfcT/rzxOuu8vxvvDSvF50pqZS6KpJUDmATedAhO54zktbE/g7RiaaYfzgX8xjRhlQdgISwA==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "solid-js": "^1.3.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -7754,6 +7775,11 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "node_modules/regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -8249,6 +8275,19 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, + "node_modules/solid-js": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.6.9.tgz", + "integrity": "sha512-kV3fMmm+1C2J95c8eDOPKGfZHnuAkHUBLG4hX1Xu08bXeAIPqmxuz/QdH3B8SIdTp3EatBVIyA6RCes3hrGzpg==", + "dependencies": { + "csstype": "^3.1.0" + } + }, + "node_modules/solid-js/node_modules/csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" + }, "node_modules/sortablejs": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", @@ -10159,6 +10198,14 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz", "integrity": "sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==" }, + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, "@braintree/sanitize-url": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", @@ -11525,6 +11572,15 @@ "printable-characters": "^1.0.42" } }, + "asciinema-player": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.0.1.tgz", + "integrity": "sha512-plm/C/MhOtZWysrfcT/rzxOuu8vxvvDSvF50pqZS6KpJUDmATedAhO54zktbE/g7RiaaYfzgX8xjRhlQdgISwA==", + "requires": { + "@babel/runtime": "^7.15.4", + "solid-js": "^1.3.0" + } + }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -15606,6 +15662,11 @@ "strip-indent": "^3.0.0" } }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -15960,6 +16021,21 @@ "socks": "^2.3.3" } }, + "solid-js": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.6.9.tgz", + "integrity": "sha512-kV3fMmm+1C2J95c8eDOPKGfZHnuAkHUBLG4hX1Xu08bXeAIPqmxuz/QdH3B8SIdTp3EatBVIyA6RCes3hrGzpg==", + "requires": { + "csstype": "^3.1.0" + }, + "dependencies": { + "csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" + } + } + }, "sortablejs": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", diff --git a/package.json b/package.json index 229d4f1aa90a3..1ad52f9cd39ae 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@primer/octicons": "17.10.0", "@vue/compiler-sfc": "3.2.45", "add-asset-webpack-plugin": "2.0.1", + "asciinema-player": "3.0.1", "css-loader": "6.7.3", "dropzone": "6.0.0-beta.2", "easymde": "2.18.0", diff --git a/web_src/js/markup/asciicast.js b/web_src/js/markup/asciicast.js new file mode 100644 index 0000000000000..d77c05b7aa5f3 --- /dev/null +++ b/web_src/js/markup/asciicast.js @@ -0,0 +1,14 @@ +export async function renderAsciinemaPlayer() { + const els = document.querySelectorAll('.asciinema-player-container'); + if (!els.length) return; + + const player = await import(/* webpackChunkName: "asciinema-player" */'asciinema-player'); + + for (const el of els) { + player.create(el.getAttribute('data-asciinema-player-src'), el, { + // poster (a preview frame) to display until the playback is started. + // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. + poster: 'npt:1:0:0', + }); + } +} diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.js index 319c229385a3d..e4ec3d0b4baa6 100644 --- a/web_src/js/markup/content.js +++ b/web_src/js/markup/content.js @@ -1,6 +1,7 @@ import {renderMermaid} from './mermaid.js'; import {renderMath} from './math.js'; import {renderCodeCopy} from './codecopy.js'; +import {renderAsciinemaPlayer} from './asciicast.js'; import {initMarkupTasklist} from './tasklist.js'; // code that runs for all markup content @@ -8,6 +9,7 @@ export function initMarkupContent() { renderMermaid(); renderMath(); renderCodeCopy(); + renderAsciinemaPlayer(); } // code that only runs for comments diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 646cf4e60e28a..4bcaf8dd040f8 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -470,6 +470,10 @@ pre { overflow: auto; } + + .asciicast { + padding: 5px !important; + } } .sidebar { diff --git a/web_src/less/index.less b/web_src/less/index.less index 2d670ac2d5d34..185bf7ca313b0 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -13,6 +13,7 @@ @import "./markup/content.less"; @import "./markup/codecopy.less"; @import "./code/linebutton.less"; +@import "./markup/asciicast.less"; @import "./chroma/base.less"; @import "./chroma/light.less"; diff --git a/web_src/less/markup/asciicast.less b/web_src/less/markup/asciicast.less new file mode 100644 index 0000000000000..468f0b4f3fabf --- /dev/null +++ b/web_src/less/markup/asciicast.less @@ -0,0 +1,10 @@ +@import "../asciinema-player/dist/bundle/asciinema-player.css"; + +.asciinema-player-container { + width: 100%; + height: auto; +} + +.asciinema-terminal { + overflow: hidden !important; +}