feat: add miller document layout
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,10 +29,11 @@ type layoutData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type indexData struct {
|
type indexData struct {
|
||||||
Documents []docs.DocumentRecord
|
Browser browserData
|
||||||
}
|
}
|
||||||
|
|
||||||
type documentData struct {
|
type documentData struct {
|
||||||
|
Browser browserData
|
||||||
Title string
|
Title string
|
||||||
Path string
|
Path string
|
||||||
Tags []string
|
Tags []string
|
||||||
@@ -44,6 +46,23 @@ type errorData struct {
|
|||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type browserData struct {
|
||||||
|
Columns []browserColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
type browserColumn struct {
|
||||||
|
Title string
|
||||||
|
Items []browserItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type browserItem struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
URL string
|
||||||
|
IsFolder bool
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := s.documents.ListDocuments(r.Context())
|
items, err := s.documents.ListDocuments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,13 +70,14 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
||||||
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
|
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
|
||||||
Title: "MD Hub Secure",
|
Title: "MD Hub Secure",
|
||||||
WebEnabled: s.webEnabled,
|
WebEnabled: s.webEnabled,
|
||||||
BodyClass: "page-index",
|
BodyClass: "page-index",
|
||||||
BodyTemplate: "index_content",
|
BodyTemplate: "index_content",
|
||||||
Data: indexData{
|
Data: indexData{
|
||||||
Documents: items,
|
Browser: buildBrowser(items, activeFolder),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,12 +98,19 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items, err := s.documents.ListDocuments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
|
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
WebEnabled: s.webEnabled,
|
WebEnabled: s.webEnabled,
|
||||||
BodyClass: "page-document",
|
BodyClass: "page-document",
|
||||||
BodyTemplate: "document_content",
|
BodyTemplate: "document_content",
|
||||||
Data: documentData{
|
Data: documentData{
|
||||||
|
Browser: buildBrowser(items, page.Path),
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
Tags: page.Tags,
|
Tags: page.Tags,
|
||||||
@@ -240,3 +267,108 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(payload)
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
||||||
|
paths := make([]string, 0, len(records))
|
||||||
|
titleByPath := make(map[string]string, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
paths = append(paths, record.Path)
|
||||||
|
titleByPath[record.Path] = record.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes := []string{""}
|
||||||
|
if activePath != "" {
|
||||||
|
parts := strings.Split(activePath, "/")
|
||||||
|
limit := len(parts)
|
||||||
|
if strings.HasSuffix(activePath, ".md") {
|
||||||
|
limit = len(parts) - 1
|
||||||
|
}
|
||||||
|
for i := 1; i <= limit; i++ {
|
||||||
|
prefixes = append(prefixes, strings.Join(parts[:i], "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := make([]browserColumn, 0, len(prefixes))
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
column := browserColumn{
|
||||||
|
Title: columnTitle(prefix),
|
||||||
|
Items: buildBrowserItems(paths, titleByPath, prefix, activePath),
|
||||||
|
}
|
||||||
|
if len(column.Items) > 0 {
|
||||||
|
columns = append(columns, column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return browserData{Columns: columns}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBrowserItems(paths []string, titleByPath map[string]string, prefix string, activePath string) []browserItem {
|
||||||
|
seenFolders := make(map[string]browserItem)
|
||||||
|
files := make([]browserItem, 0)
|
||||||
|
prefixWithSlash := ""
|
||||||
|
if prefix != "" {
|
||||||
|
prefixWithSlash = prefix + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remainder := strings.TrimPrefix(path, prefixWithSlash)
|
||||||
|
if remainder == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if slash := strings.Index(remainder, "/"); slash >= 0 {
|
||||||
|
name := remainder[:slash]
|
||||||
|
folderPath := name
|
||||||
|
if prefix != "" {
|
||||||
|
folderPath = prefix + "/" + name
|
||||||
|
}
|
||||||
|
seenFolders[folderPath] = browserItem{
|
||||||
|
Name: name,
|
||||||
|
Path: folderPath,
|
||||||
|
URL: "/?folder=" + folderPath,
|
||||||
|
IsFolder: true,
|
||||||
|
Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, browserItem{
|
||||||
|
Name: displayDocumentName(path, titleByPath[path]),
|
||||||
|
Path: path,
|
||||||
|
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
|
||||||
|
Active: activePath == path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := make([]browserItem, 0, len(seenFolders))
|
||||||
|
for _, item := range seenFolders {
|
||||||
|
folders = append(folders, item)
|
||||||
|
}
|
||||||
|
sort.Slice(folders, func(i, j int) bool {
|
||||||
|
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
|
||||||
|
})
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
items := append(folders, files...)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnTitle(prefix string) string {
|
||||||
|
if prefix == "" {
|
||||||
|
return "Content"
|
||||||
|
}
|
||||||
|
return filepath.Base(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayDocumentName(path string, title string) string {
|
||||||
|
if title != "" && title != "Untitled" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(filepath.Base(path), ".md")
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,27 +79,99 @@ code {
|
|||||||
padding: 2rem 0 4rem;
|
padding: 2rem 0 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-panel,
|
|
||||||
.doc-list,
|
|
||||||
.document-shell,
|
.document-shell,
|
||||||
.error-panel {
|
.error-panel,
|
||||||
|
.empty-preview {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-panel,
|
.error-panel,
|
||||||
.error-panel {
|
.empty-preview {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-list,
|
|
||||||
.document-shell {
|
.document-shell {
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(20rem, 0.42fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-shell--empty {
|
||||||
|
grid-template-columns: minmax(22rem, 1fr) minmax(18rem, 0.45fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-columns: minmax(12rem, 1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-height: 22rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column {
|
||||||
|
min-width: 12rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column h2 {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 700 0.76rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column a {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column a:hover,
|
||||||
|
.miller-column a.is-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-column a span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 0.8rem;
|
margin: 0 0 0.8rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -114,26 +186,12 @@ code {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-list ul,
|
|
||||||
.tag-list {
|
.tag-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-list li {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-list li:first-child {
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
.tag-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -142,14 +200,46 @@ code {
|
|||||||
|
|
||||||
.tag-list a {
|
.tag-list a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.35rem 0.65rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hash {
|
.document-meta {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-meta h1 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.85rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dt {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +311,20 @@ code {
|
|||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-list,
|
.workspace-shell,
|
||||||
|
.workspace-shell--empty {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miller-browser {
|
||||||
|
min-height: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
.document-shell {
|
.document-shell {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
|
{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
{{ define "document_content" }}
|
{{ define "document_content" }}
|
||||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
|
<section class="workspace-shell">
|
||||||
|
{{ template "browser" .Browser }}
|
||||||
|
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
|
||||||
<div class="document-meta">
|
<div class="document-meta">
|
||||||
<p class="eyebrow">{{ .Path }}</p>
|
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
|
<dl class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<dt>File</dt>
|
||||||
|
<dd><code>{{ .Path }}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Hash</dt>
|
||||||
|
<dd><code>{{ .Hash }}</code></dd>
|
||||||
|
</div>
|
||||||
{{ if .Tags }}
|
{{ if .Tags }}
|
||||||
|
<div>
|
||||||
|
<dt>Tags</dt>
|
||||||
|
<dd>
|
||||||
<ul class="tag-list">
|
<ul class="tag-list">
|
||||||
{{ range .Tags }}
|
{{ range .Tags }}
|
||||||
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
|
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<p class="hash">SHA-256: <code>{{ .Hash }}</code></p>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="markdown-body">
|
<div class="markdown-body">
|
||||||
{{ .HTML }}
|
{{ .HTML }}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "browser" }}
|
||||||
|
<nav class="miller-browser" aria-label="Documents">
|
||||||
|
{{ range .Columns }}
|
||||||
|
<section class="miller-column" aria-label="{{ .Title }}">
|
||||||
|
<h2>{{ .Title }}</h2>
|
||||||
|
<ul>
|
||||||
|
{{ range .Items }}
|
||||||
|
<li>
|
||||||
|
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}">
|
||||||
|
<span>{{ .Name }}</span>
|
||||||
|
{{ if .IsFolder }}<span aria-hidden="true">›</span>{{ end }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
</nav>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
{{ define "index.gohtml" }}{{ template "base" . }}{{ end }}
|
{{ define "index.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
{{ define "index_content" }}
|
{{ define "index_content" }}
|
||||||
<section class="hero-panel">
|
<section class="workspace-shell workspace-shell--empty">
|
||||||
<p class="eyebrow">Foundation</p>
|
{{ template "browser" .Browser }}
|
||||||
<h1>Server-rendered Markdown, secure attachments, and a clean base for sync.</h1>
|
<section class="empty-preview">
|
||||||
<p class="lede">This milestone focuses on a fast read path. Documents are synchronized from the content directory into content-addressed storage and rendered to HTML on demand.</p>
|
<p class="eyebrow">Documents</p>
|
||||||
</section>
|
<h1>MD Hub Secure</h1>
|
||||||
|
</section>
|
||||||
<section class="doc-list">
|
|
||||||
<h2>Documents</h2>
|
|
||||||
<ul>
|
|
||||||
{{ range .Documents }}
|
|
||||||
<li>
|
|
||||||
<a href="/docs/{{ trimMd .Path }}">{{ .Title }}</a>
|
|
||||||
<code>{{ .Path }}</code>
|
|
||||||
</li>
|
|
||||||
{{ else }}
|
|
||||||
<li>No documents found.</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`)
|
tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`)
|
||||||
|
firstH1Pattern = regexp.MustCompile(`(?s)^\s*<h1\b[^>]*>.*?</h1>\s*`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -61,8 +62,10 @@ func (r *Renderer) Render(content []byte) (Result, error) {
|
|||||||
return Result{}, fmt.Errorf("render markdown: %w", err)
|
return Result{}, fmt.Errorf("render markdown: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html := firstH1Pattern.ReplaceAllString(output.String(), "")
|
||||||
|
|
||||||
return Result{
|
return Result{
|
||||||
HTML: template.HTML(output.String()),
|
HTML: template.HTML(html),
|
||||||
Title: title,
|
Title: title,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user