Refactor Layout and improve search
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "headerActions"}}
|
||||||
|
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||||
|
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||||
|
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||||
|
</form>
|
||||||
|
<script src="/_/editor/lists.js"></script>
|
||||||
|
<script src="/_/editor/tables.js"></script>
|
||||||
|
<script src="/_/editor/dates.js"></script>
|
||||||
|
<script src="/_/editor/movie.js"></script>
|
||||||
|
<script src="/_/editor.js"></script>
|
||||||
|
{{end}}
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
|
||||||
break;
|
break;
|
||||||
|
case 'F':
|
||||||
|
var input = document.querySelector('.search-input');
|
||||||
|
if (!input) return;
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
|
<script src="/_/modal.js"></script>
|
||||||
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
|
<script src="/_/tree-picker.js"></script>
|
||||||
|
{{block "headScripts" .}}{{end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||||
|
{{range .Crumbs}}
|
||||||
|
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
{{if not .EditMode}}
|
||||||
|
<form class="search-form" action="/" method="get">
|
||||||
|
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{block "headerActions" .}}{{end}}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
{{block "extras" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
+52
-115
@@ -1,115 +1,52 @@
|
|||||||
<!doctype html>
|
{{define "headScripts"}}<script src="/_/page-actions.js"></script>{{end}}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{{define "headerActions"}}
|
||||||
<meta charset="UTF-8" />
|
{{if .CanEdit}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<div class="dropdown">
|
||||||
<title>{{.Title}}</title>
|
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
||||||
<link rel="icon" href="/_/favicon.ico" />
|
<div class="dropdown-menu align-right">
|
||||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
<link rel="stylesheet" href="/_/style.css" />
|
{{if not .IsRoot}}
|
||||||
<script src="/_/modal.js"></script>
|
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||||
<script src="/_/global-shortcuts.js"></script>
|
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
<script src="/_/tree-picker.js"></script>
|
{{end}}
|
||||||
<script src="/_/page-actions.js"></script>
|
</div>
|
||||||
</head>
|
</div>
|
||||||
<body>
|
{{end}}
|
||||||
<header>
|
{{end}}
|
||||||
<nav class="breadcrumb">
|
|
||||||
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
{{define "content"}}
|
||||||
{{range .Crumbs}}
|
{{if .Content}}
|
||||||
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
|
<div class="content">{{.Content}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>
|
{{if .SpecialContent}}
|
||||||
{{if not .EditMode}}
|
<div class="content">{{.SpecialContent}}</div>
|
||||||
<form class="search-form" action="/" method="get">
|
{{end}}
|
||||||
<input class="search-input" type="search" name="q" placeholder="Search…" />
|
{{if or .Content .SpecialContent}}
|
||||||
</form>
|
<script src="/_/content.js"></script>
|
||||||
{{end}}
|
<script src="/_/anchors.js"></script>
|
||||||
{{if .EditMode}}
|
<script src="/_/toc.js"></script>
|
||||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
{{end}}
|
||||||
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
{{if .Content}}
|
||||||
{{else if .CanEdit}}
|
<script src="/_/sections.js"></script>
|
||||||
<div class="dropdown">
|
{{end}}
|
||||||
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
|
{{if .Entries}}
|
||||||
<div class="dropdown-menu align-right">
|
<div class="listing">
|
||||||
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
<div class="listing-header">Contents</div>
|
||||||
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
{{range .Entries}}
|
||||||
{{if not .IsRoot}}
|
<div class="listing-item">
|
||||||
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
<span class="icon">{{.Icon}}</span>
|
||||||
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
{{end}}
|
<span class="meta">{{.Meta}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
{{end}}
|
</div>
|
||||||
</header>
|
{{else if not .Content}}
|
||||||
<main>
|
{{if not .SpecialContent}}
|
||||||
{{if .EditMode}}
|
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
{{end}}
|
||||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
{{end}}
|
||||||
<div class="editor-toolbar">
|
{{end}}
|
||||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
{{define "extras"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
|
||||||
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
|
|
||||||
</div>
|
|
||||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
|
||||||
</form>
|
|
||||||
<script src="/_/editor/lists.js"></script>
|
|
||||||
<script src="/_/editor/tables.js"></script>
|
|
||||||
<script src="/_/editor/dates.js"></script>
|
|
||||||
<script src="/_/editor/movie.js"></script>
|
|
||||||
<script src="/_/editor.js"></script>
|
|
||||||
{{else}}
|
|
||||||
{{if .Content}}
|
|
||||||
<div class="content">{{.Content}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .SpecialContent}}
|
|
||||||
<div class="content">{{.SpecialContent}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if or .Content .SpecialContent}}
|
|
||||||
<script src="/_/content.js"></script>
|
|
||||||
<script src="/_/anchors.js"></script>
|
|
||||||
<script src="/_/toc.js"></script>
|
|
||||||
{{end}}
|
|
||||||
{{if .Content}}
|
|
||||||
<script src="/_/sections.js"></script>
|
|
||||||
{{end}}
|
|
||||||
{{if .Entries}}
|
|
||||||
<div class="listing">
|
|
||||||
<div class="listing-header">Contents</div>
|
|
||||||
{{range .Entries}}
|
|
||||||
<div class="listing-item">
|
|
||||||
<span class="icon">{{.Icon}}</span>
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
<span class="meta">{{.Meta}}</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{else if not .Content}}
|
|
||||||
{{if not .SpecialContent}}
|
|
||||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</main>
|
|
||||||
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+22
-52
@@ -1,52 +1,22 @@
|
|||||||
<!doctype html>
|
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{{define "content"}}
|
||||||
<meta charset="UTF-8" />
|
{{if .Query}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
{{if .Results}}
|
||||||
<title>Search{{if .Query}}: {{.Query}}{{end}}</title>
|
<h2 class="muted search-summary">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</h2>
|
||||||
<link rel="icon" href="/_/favicon.ico" />
|
<div class="search-results">
|
||||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
{{range .Results}}
|
||||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
<article class="search-card">
|
||||||
<link rel="stylesheet" href="/_/style.css" />
|
<a class="search-card-name" href="{{.URL}}">{{.Name}}</a>
|
||||||
</head>
|
<div class="search-card-path muted">/{{.Path}}</div>
|
||||||
<body>
|
{{if .Snippet}}<div class="search-card-snippet">{{.Snippet}}</div>{{end}}
|
||||||
<header>
|
</article>
|
||||||
<nav class="breadcrumb">
|
{{end}}
|
||||||
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
</div>
|
||||||
<span class="sep">/</span><span>search</span>
|
{{else}}
|
||||||
</nav>
|
<p class="empty">No matches for “{{.Query}}”.</p>
|
||||||
<form class="search-form" action="/" method="get">
|
{{end}}
|
||||||
<input class="search-input" type="search" name="q" value="{{.Query}}" placeholder="Search folders…" autofocus />
|
{{else}}
|
||||||
<label class="search-toggle muted" title="Also search page contents">
|
<p class="empty">Enter a query above.</p>
|
||||||
<input type="checkbox" name="full" value="1" {{if .Full}}checked{{end}} />
|
{{end}}
|
||||||
full-text
|
{{end}}
|
||||||
</label>
|
|
||||||
<button class="btn" type="submit">GO</button>
|
|
||||||
</form>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
{{if .Query}}
|
|
||||||
{{if .Results}}
|
|
||||||
<div class="listing">
|
|
||||||
<div class="listing-header">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</div>
|
|
||||||
{{range .Results}}
|
|
||||||
<div class="listing-item">
|
|
||||||
<div class="search-result">
|
|
||||||
<div class="search-result-row">
|
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
|
||||||
<span class="meta">{{.Path}}</span>
|
|
||||||
</div>
|
|
||||||
{{if .Snippet}}<div class="search-snippet muted">{{.Snippet}}</div>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">No folders match “{{.Query}}”.</p>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">Enter a query above.</p>
|
|
||||||
{{end}}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+27
-24
@@ -379,37 +379,40 @@ textarea {
|
|||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
border-color: var(--primary-hover);
|
border-color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
.search-toggle {
|
.search-summary {
|
||||||
display: flex;
|
margin-bottom: 1rem;
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.search-result {
|
.search-results {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 1rem;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.search-result-row {
|
.search-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.25rem;
|
||||||
min-width: 0;
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px dashed var(--secondary);
|
||||||
}
|
}
|
||||||
.search-result-row a {
|
.search-card:last-child {
|
||||||
flex: 1;
|
border-bottom: none;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.search-snippet {
|
.search-card-name {
|
||||||
font-size: 0.8rem;
|
color: var(--link);
|
||||||
line-height: 1.4;
|
font-size: 1.1rem;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
white-space: nowrap;
|
.search-card-name:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
.search-card-path {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.search-card-snippet {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Muted text === */
|
/* === Muted text === */
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import (
|
|||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
var (
|
||||||
|
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page.html"))
|
||||||
|
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/edit.html"))
|
||||||
|
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search.html"))
|
||||||
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
// Content is injected into the page after the standard markdown content.
|
// Content is injected into the page after the standard markdown content.
|
||||||
@@ -226,7 +230,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := tmpl.Execute(w, data); err != nil {
|
t := pageTmpl
|
||||||
|
if editMode {
|
||||||
|
t = editTmpl
|
||||||
|
}
|
||||||
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,108 +12,51 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
var searchTmpl = template.Must(template.New("search.html").ParseFS(assets, "assets/search.html"))
|
|
||||||
|
|
||||||
// Match ranks. Lower is better. Used for folder-name search (Phase 1).
|
|
||||||
const (
|
|
||||||
rankExact = 0
|
|
||||||
rankPrefix = 1
|
|
||||||
rankSubstring = 2
|
|
||||||
rankFuzzy = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
type searchResult struct {
|
type searchResult struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Path string
|
Path string
|
||||||
Rank int // Phase 1 only
|
Score int // number of query tokens that hit
|
||||||
Score int // Phase 2: number of query tokens that hit
|
NameHit bool // at least one hit came from the folder name
|
||||||
NameHit bool // Phase 2: at least one hit came from the folder name
|
Snippet string // ~300 chars around first body hit, or page stub for name-only hits
|
||||||
Snippet string // Phase 2: ~100 chars around first body hit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Query string
|
Title string
|
||||||
Full bool
|
Crumbs []crumb
|
||||||
Results []searchResult
|
EditMode bool
|
||||||
|
Query string
|
||||||
|
Results []searchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSearch walks the wiki root and renders a search results page for the
|
// handleSearch walks the wiki root and renders a search results page for the
|
||||||
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
|
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
|
||||||
// present. With ?full=1 it also scans index.md bodies (Phase 2).
|
// present.
|
||||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
full := r.URL.Query().Get("full") == "1"
|
results := searchWiki(h.root, query)
|
||||||
|
|
||||||
var results []searchResult
|
title := "Search"
|
||||||
if full {
|
if query != "" {
|
||||||
results = searchFull(h.root, query)
|
title = "Search: " + query
|
||||||
} else {
|
}
|
||||||
results = searchFolders(h.root, query)
|
data := searchPageData{
|
||||||
|
Title: title,
|
||||||
|
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||||
|
Query: query,
|
||||||
|
Results: results,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := searchTmpl.Execute(w, searchPageData{Query: query, Full: full, Results: results}); err != nil {
|
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
log.Printf("search template error: %v", err)
|
log.Printf("search template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchFolders walks root and returns directories whose final path segment
|
// searchWiki walks root and scores each directory by how many whitespace-split
|
||||||
// matches the query, ranked best-first. Returns nil for an empty query.
|
|
||||||
func searchFolders(root, query string) []searchResult {
|
|
||||||
if query == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
q := strings.ToLower(query)
|
|
||||||
maxDist := 2
|
|
||||||
if len([]rune(q)) > 6 {
|
|
||||||
maxDist = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
walkRoot := resolveWalkRoot(root)
|
|
||||||
var results []searchResult
|
|
||||||
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if !d.IsDir() || fsPath == walkRoot {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name := d.Name()
|
|
||||||
rank, ok := matchRank(strings.ToLower(name), q, maxDist)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
|
||||||
if relErr != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
results = append(results, searchResult{
|
|
||||||
Name: name,
|
|
||||||
URL: "/" + filepath.ToSlash(rel) + "/",
|
|
||||||
Path: filepath.ToSlash(rel),
|
|
||||||
Rank: rank,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
sort.SliceStable(results, func(i, j int) bool {
|
|
||||||
if results[i].Rank != results[j].Rank {
|
|
||||||
return results[i].Rank < results[j].Rank
|
|
||||||
}
|
|
||||||
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
|
|
||||||
})
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchFull walks root and scores each directory by how many whitespace-split
|
|
||||||
// query tokens hit a word in either the folder name or its index.md body.
|
// query tokens hit a word in either the folder name or its index.md body.
|
||||||
// A word "hits" a token via case-insensitive equality or Levenshtein ≤ 2.
|
// A word "hits" a token via case-insensitive equality or Levenshtein ≤ 2.
|
||||||
// Folder-name hits break score ties above content-only hits.
|
// Folder-name hits break score ties above content-only hits.
|
||||||
func searchFull(root, query string) []searchResult {
|
func searchWiki(root, query string) []searchResult {
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -209,24 +151,6 @@ func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchRank returns the best (lowest) rank for which name matches q, or
|
|
||||||
// (0, false) if no rule matches. Inputs are expected to be lowercased.
|
|
||||||
func matchRank(name, q string, maxDist int) (int, bool) {
|
|
||||||
if name == q {
|
|
||||||
return rankExact, true
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(name, q) {
|
|
||||||
return rankPrefix, true
|
|
||||||
}
|
|
||||||
if strings.Contains(name, q) {
|
|
||||||
return rankSubstring, true
|
|
||||||
}
|
|
||||||
if levenshtein(name, q) <= maxDist {
|
|
||||||
return rankFuzzy, true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenize splits s into lowercase word tokens, breaking on any rune that is
|
// tokenize splits s into lowercase word tokens, breaking on any rune that is
|
||||||
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
|
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
|
||||||
func tokenize(s string) []string {
|
func tokenize(s string) []string {
|
||||||
@@ -264,10 +188,13 @@ func tokenInWords(qt string, words []string) bool {
|
|||||||
|
|
||||||
var snippetWS = regexp.MustCompile(`\s+`)
|
var snippetWS = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
// makeSnippet returns ~100 characters of body around the earliest substring
|
const snippetWindow = 300
|
||||||
// match of any query token. Falls back to empty when no token appears as a
|
|
||||||
// substring (a token may have hit only via Levenshtein, with no exact span to
|
// makeSnippet returns ~300 characters of body around the earliest substring
|
||||||
// quote).
|
// match of any query token. When no token has an exact substring span (e.g.
|
||||||
|
// matched only via Levenshtein, or the hit was folder-name-only), it falls
|
||||||
|
// back to the first ~300 chars of the body with the leading heading stripped.
|
||||||
|
// Returns "" only when the body itself is empty.
|
||||||
func makeSnippet(body, bodyLower string, tokens []string) string {
|
func makeSnippet(body, bodyLower string, tokens []string) string {
|
||||||
pos := -1
|
pos := -1
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
@@ -280,31 +207,80 @@ func makeSnippet(body, bodyLower string, tokens []string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pos < 0 {
|
if pos < 0 {
|
||||||
return ""
|
return makeStub(body)
|
||||||
}
|
}
|
||||||
start := pos - 50
|
|
||||||
|
half := snippetWindow / 2
|
||||||
|
start := pos - half
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
end := pos + 50
|
end := pos + half
|
||||||
if end > len(body) {
|
if end > len(body) {
|
||||||
end = len(body)
|
end = len(body)
|
||||||
}
|
}
|
||||||
for start > 0 && body[start]&0xC0 == 0x80 {
|
start, end = expandToWordBoundaries(body, start, end)
|
||||||
start--
|
out := snippetWS.ReplaceAllString(body[start:end], " ")
|
||||||
}
|
out = strings.TrimSpace(out)
|
||||||
for end < len(body) && body[end]&0xC0 == 0x80 {
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
s := snippetWS.ReplaceAllString(body[start:end], " ")
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if start > 0 {
|
if start > 0 {
|
||||||
s = "…" + s
|
out = "…" + out
|
||||||
}
|
}
|
||||||
if end < len(body) {
|
if end < len(body) {
|
||||||
s = s + "…"
|
out = out + "…"
|
||||||
}
|
}
|
||||||
return s
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeStub returns ~snippetWindow chars from the start of body, with the
|
||||||
|
// leading "# Heading" line stripped. Returns "" for an empty body.
|
||||||
|
func makeStub(body string) string {
|
||||||
|
stripped := string(stripFirstHeading([]byte(body)))
|
||||||
|
stripped = strings.TrimSpace(stripped)
|
||||||
|
if stripped == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := snippetWindow
|
||||||
|
if end > len(stripped) {
|
||||||
|
end = len(stripped)
|
||||||
|
}
|
||||||
|
_, end = expandToWordBoundaries(stripped, 0, end)
|
||||||
|
out := snippetWS.ReplaceAllString(stripped[:end], " ")
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if end < len(stripped) {
|
||||||
|
out = out + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandToWordBoundaries adjusts start/end so they don't split a word and
|
||||||
|
// don't fall in the middle of a UTF-8 sequence. start moves forward past
|
||||||
|
// any partial word at the beginning; end moves backward to the previous
|
||||||
|
// word boundary.
|
||||||
|
func expandToWordBoundaries(s string, start, end int) (int, int) {
|
||||||
|
for start > 0 && start < len(s) && s[start]&0xC0 == 0x80 {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for end < len(s) && s[end]&0xC0 == 0x80 {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if start > 0 && start < len(s) && isWordByte(s[start-1]) && isWordByte(s[start]) {
|
||||||
|
for start < end && isWordByte(s[start]) {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end < len(s) && isWordByte(s[end-1]) && isWordByte(s[end]) {
|
||||||
|
for end > start && isWordByte(s[end-1]) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWordByte(b byte) bool {
|
||||||
|
if b&0x80 != 0 {
|
||||||
|
return true // assume any multibyte char is part of a word
|
||||||
|
}
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||||
}
|
}
|
||||||
|
|
||||||
// levenshtein returns the edit distance between a and b. Operates on runes so
|
// levenshtein returns the edit distance between a and b. Operates on runes so
|
||||||
|
|||||||
Reference in New Issue
Block a user