feat: replace Monaco by Ace
All checks were successful
/ pre-commit (push) Successful in 1m20s

- Remove the Monaco Editor because it was to heavy and hard to integrate
- Use Ace instead
- Use the lowercase identifier for languages (ex: "Text" -> "text")
- Select automatically the default language in the drop down to create a note
  (like the expiration)
- Add `ace_directory` to serve assets from a local folder instead of a CDN
- "hcl" syntax highlighting has been removed
- "go" syntax highlighting has been renamed to "golang"
- Add option to disable the editor

Fixes #32.

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2025-09-21 15:03:39 +02:00
commit b5701b5a4d
Signed by: jriou
GPG key ID: 9A099EDA51316854
9 changed files with 113 additions and 73 deletions

6
package-lock.json generated
View file

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"ace-builds": "^1.43.3",
"bootstrap": "^5.3.8" "bootstrap": "^5.3.8"
} }
}, },
@ -18,6 +19,11 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/ace-builds": {
"version": "1.43.3",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.3.tgz",
"integrity": "sha512-MCl9rALmXwIty/4Qboijo/yNysx1r6hBTzG+6n/TiOm5LFhZpEvEIcIITPFiEOEFDfgBOEmxu+a4f54LEFM6Sg=="
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",

View file

@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"ace-builds": "^1.43.3",
"bootstrap": "^5.3.8" "bootstrap": "^5.3.8"
} }
} }

View file

@ -35,11 +35,13 @@ The file format is **JSON**:
* **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics") * **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics")
* **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes") * **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes")
* **observation_internal** (int): Number of seconds to wait between two observations (default 60) * **observation_internal** (int): Number of seconds to wait between two observations (default 60)
* **languages** ([]string): List of supported [languages](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages) * **languages** ([]string): List of supported [languages](https://github.com/ajaxorg/ace/tree/master/src/mode)
* **language** (string): Default language (default "text") * **language** (string): Default language (default "text")
* **tls_cert_file** (string): Path to TLS certificate file to enable HTTPS * **tls_cert_file** (string): Path to TLS certificate file to enable HTTPS
* **tls_key_file** (string): Path to TLS key file to enable HTTPS * **tls_key_file** (string): Path to TLS key file to enable HTTPS
* **ace_directory** (string): Serve [Ace](hhttps://ace.c9.io/) assets from this local directory (ex: "./node_modules/ace-builds"). See **Dependencies** for details.
* **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details. * **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details.
* **disable_editor** (bool): Disable Ace editor.
The configuration file is not required but the service might not be exposed to the public. The configuration file is not required but the service might not be exposed to the public.
@ -107,12 +109,12 @@ Errors return **500 Server Internal Error** with the **JSON** payload:
The web interface depends on: The web interface depends on:
- [Ace](https://ace.c9.io/)
- [Bootstrap](https://getbootstrap.com/) - [Bootstrap](https://getbootstrap.com/)
- [Monaco Editor](https://github.com/microsoft/monaco-editor/)
By default, those dependencies are fetched from **remote CDN** services by the client. By default, those dependencies are fetched from **remote CDN** services by the client.
If you would like to download them to serve them locally: If you would like to download and serve them locally:
``` ```
npm install npm install
@ -128,8 +130,7 @@ Then configure the local directories:
```json ```json
{ {
"ace_directory": "./node_modules/ace-builds",
"bootstrap_directory": "./node_modules/bootstrap/dist" "bootstrap_directory": "./node_modules/bootstrap/dist"
} }
``` ```
Downloading Monaco Editor is not supported yet.

View file

@ -31,7 +31,9 @@ type Config struct {
Language string `json:"language"` Language string `json:"language"`
TLSCertFile string `json:"tls_cert_file"` TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"` TLSKeyFile string `json:"tls_key_file"`
AceDirectory string `json:"ace_directory"`
BootstrapDirectory string `json:"bootstrap_directory"` BootstrapDirectory string `json:"bootstrap_directory"`
DisableEditor bool `json:"disable_editor"`
} }
func NewConfig() *Config { func NewConfig() *Config {
@ -59,22 +61,21 @@ func NewConfig() *Config {
PrometheusNotesMetric: "collerd_notes", PrometheusNotesMetric: "collerd_notes",
ObservationInterval: 60, ObservationInterval: 60,
Languages: []string{ Languages: []string{
"Text", "css",
"CSS", "dockerfile",
"Dockerfile", "golang",
"Go", "html",
"HCL", "javascript",
"HTML", "json",
"Javascript", "markdown",
"JSON", "perl",
"Markdown", "python",
"Perl", "ruby",
"Python", "rust",
"Ruby", "sh",
"Rust", "sql",
"Shell", "text",
"SQL", "yaml",
"YAML",
}, },
Language: "text", Language: "text",
EnableUploadFileButton: true, EnableUploadFileButton: true,

View file

@ -23,6 +23,7 @@ type PageData struct {
Expirations []int Expirations []int
Expiration int Expiration int
Languages []string Languages []string
Language string
Err error Err error
URL string URL string
Note *Note Note *Note
@ -30,7 +31,9 @@ type PageData struct {
EnableUploadFileButton bool EnableUploadFileButton bool
AllowClientEncryptionKey bool AllowClientEncryptionKey bool
AllowNoEncryption bool AllowNoEncryption bool
AceDirectory string
BootstrapDirectory string BootstrapDirectory string
DisableEditor bool
} }
type HomeHandler struct { type HomeHandler struct {

View file

@ -120,6 +120,9 @@ func (s *Server) Start() error {
Expirations: s.config.Expirations, Expirations: s.config.Expirations,
Expiration: s.config.Expiration, Expiration: s.config.Expiration,
Languages: s.config.Languages, Languages: s.config.Languages,
Language: s.config.Language,
AceDirectory: s.config.AceDirectory,
DisableEditor: s.config.DisableEditor,
BootstrapDirectory: s.config.BootstrapDirectory, BootstrapDirectory: s.config.BootstrapDirectory,
EnableUploadFileButton: s.config.EnableUploadFileButton, EnableUploadFileButton: s.config.EnableUploadFileButton,
EnablePasswordProtection: s.config.EnablePasswordProtection, EnablePasswordProtection: s.config.EnablePasswordProtection,
@ -225,6 +228,10 @@ func (s *Server) Start() error {
} }
r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("POST") r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("POST")
if s.config.AceDirectory != "" {
r.PathPrefix("/static/ace-builds/").Handler(http.StripPrefix("/static/ace-builds/", http.FileServer(http.Dir(s.config.AceDirectory))))
}
if s.config.BootstrapDirectory != "" { if s.config.BootstrapDirectory != "" {
r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory)))) r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory))))
} }
@ -234,10 +241,10 @@ func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort) addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
if s.config.HasTLS() { if s.config.HasTLS() {
s.logger.Info(fmt.Sprintf("listening to %s:%d (https)", s.config.ListenAddress, s.config.ListenPort)) s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "http"))
return http.ListenAndServeTLS(addr, s.config.TLSCertFile, s.config.TLSKeyFile, r) return http.ListenAndServeTLS(addr, s.config.TLSCertFile, s.config.TLSKeyFile, r)
} else { } else {
s.logger.Info(fmt.Sprintf("listening to %s:%d (http)", s.config.ListenAddress, s.config.ListenPort)) s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "https"))
return http.ListenAndServe(addr, r) return http.ListenAndServe(addr, r)
} }
} }

View file

@ -4,6 +4,8 @@
<title>{{.Title}}</title> <title>{{.Title}}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="{{if ne .BootstrapDirectory ``}}/static/bootstrap/css/bootstrap.min.css{{else}}https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css{{end}}" rel="stylesheet"> <link
href="{{if .BootstrapDirectory}}/static/bootstrap/css/bootstrap.min.css{{else}}https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css{{end}}"
rel="stylesheet">
</head> </head>
{{end}} {{end}}

View file

@ -59,9 +59,10 @@
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" aria-label="Language" id="language" name="language"> <select class="form-select" aria-label="Language" id="language" name="language">
<option selected="selected" value="" disabled>Language</option> <option disabled>Language</option>
{{range .Languages}} {{range $language := .Languages}}
<option value="{{lower .}}">{{.}}</option> <option {{ if eq $language $.Language }}selected="selected" {{end}}value="{{lower .}}">
{{$language}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
@ -69,11 +70,15 @@
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
{{if .DisableEditor}}
<textarea class="form-control" id="content" name="content" cols="40" rows="12"></textarea>
{{else}}
<div class="row"> <div class="row">
<div id="editor" name="editor" class="form-control" <div id="editor" name="editor" class="form-control"
style="height: 300px; resize: vertical; overflow: auto;"></div> style="min-height: 300px; resize: vertical; overflow: auto;"></div>
<input type="hidden" id="content" /> <input type="hidden" id="content" />
</div> </div>
{{end}}
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
<div class="row text-center justify-content-center"> <div class="row text-center justify-content-center">
@ -82,38 +87,40 @@
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script> {{if eq false .DisableEditor}}
<script
src="{{if .AceDirectory}}/static/ace-builds{{else}}https://cdn.jsdelivr.net/npm/ace-builds@1.43.3{{end}}/src-noconflict/ace.js"
type="text/javascript" charset="utf-8"></script>
<script> <script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } }); var editor = ace.edit("editor");
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
editor.setTheme("ace/theme/github_light_default");
} else {
editor.setTheme("ace/theme/github_dark");
}
require(['vs/editor/editor.main'], function () { // Syntax highlighting
var editor = monaco.editor.create(document.getElementById('editor'), { document.getElementById("language").addEventListener("change", (e) => {
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark", if (e.target.value != "") {
language: document.getElementById("language").value, editor.getSession().setMode("ace/mode/" + e.target.value);
}); }
});
// Syntax highlighting // Dark mode
document.getElementById("language").addEventListener("change", (e) => { document.getElementById("lightSwitch").addEventListener("click", () => {
if (e.target.value != "") { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
monaco.editor.setModelLanguage(editor.getModel(), e.target.value); editor.setTheme("ace/theme/github_light_default")
} } else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
}); editor.setTheme("ace/theme/github_dark")
}
});
// Dark mode // Copy content on submit
document.getElementById("lightSwitch").addEventListener("click", () => { document.getElementById("form").addEventListener("formdata", (e) => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') { e.formData.append('content', editor.getValue());
monaco.editor.setTheme("vs")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
monaco.editor.setTheme("vs-dark")
}
});
// Copy content on submit
document.getElementById("form").addEventListener("formdata", (e) => {
e.formData.append('content', editor.getModel().getValue());
});
}); });
</script> </script>
{{end}}
</form> </form>
{{block "footer" .}}{{end}} {{block "footer" .}}{{end}}

View file

@ -20,32 +20,44 @@
{{end}} {{end}}
</ul> </ul>
</div> </div>
{{if .DisableEditor}}
<div class="row"> <div class="row">
<div id="editor" name="editor" class="form-control" style="height: 300px; resize: vertical; overflow: auto;"> <pre class="border px-3 pt-3" style="min-height: 300px; resize: vertical; overflow: auto;">
{{string .Note.Content}}
</pre>
</div>
{{else}}
<div class="row">
<div id="editor" name="editor" class="form-control" style="min-height: 300px; resize: vertical; overflow: auto;">
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script> <script
src="{{if .AceDirectory}}/static/ace-builds{{else}}https://cdn.jsdelivr.net/npm/ace-builds@1.43.3{{end}}/src-noconflict/ace.js"
type="text/javascript" charset="utf-8"></script>
<script> <script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } }); var editor = ace.edit("editor");
editor.setValue("{{string .Note.Content}}");
editor.setReadOnly(true);
editor.getSession().setMode("ace/mode/{{.Note.Language}}");
editor.getSession().selection.clearSelection();
editor.setOptions({ maxLines: Infinity });
require(['vs/editor/editor.main'], function () { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
var editor = monaco.editor.create(document.getElementById('editor'), { editor.setTheme("ace/theme/github_light_default");
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark", } else {
language: "{{.Note.Language}}", editor.setTheme("ace/theme/github_dark");
readOnly: true, }
value: "{{string .Note.Content}}"
});
// Dark mode // Dark mode
document.getElementById("lightSwitch").addEventListener("click", () => { document.getElementById("lightSwitch").addEventListener("click", () => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
monaco.editor.setTheme("vs") editor.setTheme("ace/theme/github_light_default")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') { } else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
monaco.editor.setTheme("vs-dark") editor.setTheme("ace/theme/github_dark")
} }
});
}); });
</script> </script>
{{end}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}