feat(claude): track agents and the matej and notes skills
This commit is contained in:
116
claude/skills/notes/SKILL.md
Normal file
116
claude/skills/notes/SKILL.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: notes
|
||||
description: Manage the project's local .notes/ dir (design docs, work handoffs, parked questions). Use whenever writing a design doc, plan, proposal, research or review for local use; starting, handing off or closing out work that spans sessions (brief, plan, report); parking something for later, out of scope or a follow-up; picking up or settling a parked question; preparing a document for someone else; or when the user mentions .notes, handoffs, open questions or asks what is pending.
|
||||
---
|
||||
|
||||
# Notes
|
||||
|
||||
The policy is `~/.config/claude/rules/notes.md`: layout, numbering, status lines, close-out, what doesn't belong. This skill holds the procedures. Every step that changes `.notes/` ends with `commit`, which also regenerates `index.html`, `notes.css`, the markdown pages under `_pages/` and the portable copies under `_out/`. Change the look only in this skill's `notes.css`, never in a project.
|
||||
|
||||
```
|
||||
N=~/.config/claude/skills/notes/notes.py
|
||||
python3 $N init # create .notes/ if missing (idempotent)
|
||||
python3 $N next d|w|q # next free id, counts deleted items too
|
||||
python3 $N commit "<subject>" # regenerate the generated pages, commit all of .notes/
|
||||
python3 $N index # regenerate index.html, notes.css, _pages/ and _out/ only
|
||||
python3 $N serve # every project's notes on http://127.0.0.1:8765/: hub filters projects, each project page searches its notes (the user runs this)
|
||||
```
|
||||
|
||||
All commands take `--root <project>`; the default is the git toplevel of the current dir. Never pick an id by hand or from `ls`; deleted ids live only in git history.
|
||||
|
||||
## New doc
|
||||
|
||||
1. `init`, then `next d`
|
||||
2. write `docs/dNNN-slug.html` (or `docs/dNNN-slug/index.html` with its assets next to it, then every `../` below becomes `../../`) on this skeleton, using only the components documented at the top of `notes.css` (callout, tag, cards, grid-2, table, figure.diagram with its svg classes); no `<style>`, no font links:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Short name</title>
|
||||
<link rel="stylesheet" href="../notes.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<nav class="back"><a href="../index.html">← notes</a></nav>
|
||||
<header>
|
||||
<p class="eyebrow">dNNN · plan · 2026-10-14</p>
|
||||
<h1>Title</h1>
|
||||
<p class="status current">status: current</p>
|
||||
<p class="lede">What this doc decides, in one or two sentences.</p>
|
||||
</header>
|
||||
<section>
|
||||
<h2>…</h2>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
3. if it replaces an older doc, set the older one's status line to `<p class="status superseded">status: superseded by <a href="dNNN-slug.html">dNNN</a> (<date>)</p>`; mind the relative path when either doc is a folder
|
||||
4. look at it: a headless browser screenshot in light and dark, or `serve`
|
||||
5. `commit "dNNN: <slug>"`
|
||||
|
||||
## New work item
|
||||
|
||||
Only for work that will span sessions.
|
||||
|
||||
1. `init`, then `next w`, create `work/wNNN-slug/`
|
||||
2. `brief.md`, starting with `# wNNN <title>`: goal, done when, scope, verification, decisions to ask, prerequisites to read
|
||||
3. add `plan.html` (the proposal, on the doc skeleton with `../../notes.css` and `../../index.html`) and research files as the work goes; `report.md` at the end: what was built, what differs from the plan, leftovers
|
||||
4. `commit "wNNN: <what changed>"`
|
||||
|
||||
## Close-out
|
||||
|
||||
When the work's branch is merged or the work is dropped, in that same session:
|
||||
|
||||
1. lasting design → new doc or a status line on an existing one (see above)
|
||||
2. each leftover → a question with `origin: wNNN`
|
||||
3. `rm -r work/wNNN-slug`, then `commit "wNNN: closed, <merged as <sha>|dropped>"`
|
||||
|
||||
When `index` shows a work folder untouched for weeks with no open branch in the project, tell the user; don't close it yourself.
|
||||
|
||||
## Park a question
|
||||
|
||||
1. `init`, then `next q`
|
||||
2. write `questions/qNNN-slug.md`:
|
||||
|
||||
```
|
||||
---
|
||||
created: 2026-10-14
|
||||
origin: w009
|
||||
status: open
|
||||
---
|
||||
|
||||
# <one-line question or follow-up>
|
||||
|
||||
What is open and why it matters. What is known so far, with paths, commits and ids.
|
||||
Where to start looking.
|
||||
```
|
||||
|
||||
3. `commit "qNNN: <slug>"`
|
||||
|
||||
Write it so a session with no memory of today can act on it.
|
||||
|
||||
## Settle a question
|
||||
|
||||
1. check the repo first: it may already be done or moot
|
||||
2. fold the outcome into a doc or a work item if it is worth keeping
|
||||
3. `rm questions/qNNN-slug.md`, `commit "qNNN: settled, <how: done in <sha> | moot because ... | moved to wNNN>"`
|
||||
|
||||
## Other
|
||||
|
||||
- `other/in/`: the user's files; read only when pointed at
|
||||
- `other/out/`: documents for someone else, written for that reader; no ids, no status line
|
||||
- html follows the doc skeleton and components like any doc, with `../../notes.css` and `../../index.html`; the eyebrow names the reader and date instead of an id (`for the team · 2026-10-14`)
|
||||
- what gets sent is the generated `_out/<file>`: `notes.css` inlined, back link dropped, other files copied; the index links it as "to send"
|
||||
- look at the `_out/` copy too before handing it over, then `commit "other: <file>"`
|
||||
|
||||
## Never
|
||||
|
||||
- stage `.notes/` from the project repo (`git add .`, `git add -A`); stage project paths explicitly
|
||||
- reference `.notes/` paths from committed project files
|
||||
- keep archives or dated copies; delete, git keeps it
|
||||
- push `.notes/` or add a remote
|
||||
961
claude/skills/notes/notes.css
Normal file
961
claude/skills/notes/notes.css
Normal file
@@ -0,0 +1,961 @@
|
||||
/*
|
||||
* notes.css: the one stylesheet for every page in a project's .notes/
|
||||
* notes.py copies it to .notes/notes.css; edit it here, in the notes skill, never in a project
|
||||
*
|
||||
* gruvbox material (sainnhe/gruvbox-material: medium contrast, material foreground), light or dark
|
||||
* by the system scheme; jetbrains mono for all text
|
||||
*
|
||||
* page skeleton for a doc (docs/dNNN-slug.html; one level deeper for a folder doc):
|
||||
* <link rel="stylesheet" href="../notes.css">
|
||||
* <body><main class="page">
|
||||
* <nav class="back"><a href="../index.html">← notes</a></nav>
|
||||
* <header>
|
||||
* <p class="eyebrow">d007 · 2026-10-13</p>
|
||||
* <h1>Title</h1>
|
||||
* <p class="status current|stale|superseded">status: …</p>
|
||||
* <p class="lede">one or two sentences</p>
|
||||
* </header>
|
||||
* <section><h2>…</h2> …</section>
|
||||
* </main></body>
|
||||
*
|
||||
* components:
|
||||
* .callout (+ .good .warn .bad .info) a boxed note, recommendation, open point
|
||||
* .tag (+ .good .warn .bad .info .muted) a small inline label
|
||||
* .cards > .card (.card-id, strong, small) a grid of linked or plain cards
|
||||
* .grid-2 two columns, one on a phone
|
||||
* .table > table a table that scrolls sideways when wide
|
||||
* figure.diagram > svg (+ figcaption) an inline svg diagram, see "diagrams" below
|
||||
* dl.facts > dt + dd label and value pairs in two columns (repro, cause, fix)
|
||||
* pre.diff > span(.add .del) a unified diff, one span per line
|
||||
* .legend > span > svg + text a row of notation keys above a diagram
|
||||
* .swatch (+ .info .good .warn .bad) a small colour square that matches a diagram box
|
||||
* main.page.wide a wider page, for pages carried by wide diagrams
|
||||
* .sub, .muted secondary text
|
||||
* .fix a line prefixed with an arrow
|
||||
*/
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--radius: 0.375rem;
|
||||
|
||||
/* gruvbox material light */
|
||||
--red: #c14a4a;
|
||||
--orange: #c35e0a;
|
||||
--yellow: #b47109;
|
||||
--green: #6c782e;
|
||||
--aqua: #4c7a5d;
|
||||
--blue: #45707a;
|
||||
--purple: #945e80;
|
||||
|
||||
--background: #fbf1c7;
|
||||
--foreground: #654735;
|
||||
--card: #f4e8be;
|
||||
--muted: #eee0b7;
|
||||
--muted-foreground: #7c6f64;
|
||||
--border: #ddccab;
|
||||
--input: #a89984;
|
||||
--link: var(--blue);
|
||||
|
||||
/* text colours and their tints, both follow the scheme's palette */
|
||||
--good: var(--green);
|
||||
--warn: var(--yellow);
|
||||
--bad: var(--red);
|
||||
--info: var(--blue);
|
||||
--good-bg: color-mix(in srgb, var(--green) 14%, transparent);
|
||||
--warn-bg: color-mix(in srgb, var(--yellow) 16%, transparent);
|
||||
--bad-bg: color-mix(in srgb, var(--red) 13%, transparent);
|
||||
--info-bg: color-mix(in srgb, var(--blue) 13%, transparent);
|
||||
--accent: var(--orange);
|
||||
--accent-bg: color-mix(in srgb, var(--orange) 12%, transparent);
|
||||
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* gruvbox material dark */
|
||||
--red: #ea6962;
|
||||
--orange: #e78a4e;
|
||||
--yellow: #d8a657;
|
||||
--green: #a9b665;
|
||||
--aqua: #89b482;
|
||||
--blue: #7daea3;
|
||||
--purple: #d3869b;
|
||||
|
||||
--background: #282828;
|
||||
--foreground: #d4be98;
|
||||
--card: #32302f;
|
||||
--muted: #45403d;
|
||||
--muted-foreground: #a89984;
|
||||
--border: #45403d;
|
||||
--input: #5a524c;
|
||||
--good-bg: color-mix(in srgb, var(--green) 16%, transparent);
|
||||
--warn-bg: color-mix(in srgb, var(--yellow) 15%, transparent);
|
||||
--bad-bg: color-mix(in srgb, var(--red) 16%, transparent);
|
||||
--info-bg: color-mix(in srgb, var(--blue) 16%, transparent);
|
||||
--accent-bg: color-mix(in srgb, var(--orange) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* base */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: 400 14px/1.65 var(--font-mono);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
main.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 96px;
|
||||
}
|
||||
main.page.wide {
|
||||
max-width: 1240px;
|
||||
}
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
margin: 44px 0 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 28px 0 8px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
margin: 20px 0 6px;
|
||||
}
|
||||
section > h2:first-child {
|
||||
margin-top: 40px;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
pre,
|
||||
figure,
|
||||
.table,
|
||||
.callout,
|
||||
.cards,
|
||||
.grid-2,
|
||||
details {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
li > ul,
|
||||
li > ol {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
strong,
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 32px 0;
|
||||
}
|
||||
blockquote {
|
||||
padding: 2px 0 2px 16px;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.93em;
|
||||
}
|
||||
code {
|
||||
background: var(--muted);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
pre {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
/* page chrome */
|
||||
|
||||
nav.back {
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
nav.back a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
nav.back a:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
header > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.eyebrow {
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
.lede {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.sub,
|
||||
.muted {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
p.status {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
p.status.current {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
p.status.stale {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.fix::before {
|
||||
content: "→ ";
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* components */
|
||||
|
||||
.callout {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--input);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.callout > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.callout.good {
|
||||
background: var(--good-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--good);
|
||||
}
|
||||
.callout.warn {
|
||||
background: var(--warn-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.callout.bad {
|
||||
background: var(--bad-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
.callout.info {
|
||||
background: var(--info-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font: 500 11px/1.5 var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 8px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
vertical-align: 1px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.tag.good {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
.tag.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.tag.bad {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
.tag.info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
display: block;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
a.card:hover {
|
||||
border-color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-id {
|
||||
display: block;
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
.card strong {
|
||||
display: block;
|
||||
}
|
||||
.card small {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.card > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
td > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
td .tag {
|
||||
white-space: normal;
|
||||
}
|
||||
td code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
details {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
details[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
dl.facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 6px 16px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
dl.facts dt {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
dl.facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
dl.facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
dl.facts dd + dt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* one column as wide as the longest line, so every line's tint spans it when scrolled */
|
||||
pre.diff {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(max-content, 1fr);
|
||||
}
|
||||
pre.diff span {
|
||||
white-space: pre;
|
||||
}
|
||||
pre.diff .add {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
pre.diff .del {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 22px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.legend svg {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--muted-foreground);
|
||||
background: var(--card);
|
||||
vertical-align: 0;
|
||||
}
|
||||
.swatch.info {
|
||||
background: color-mix(in srgb, var(--blue) 14%, var(--card));
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.swatch.good {
|
||||
background: color-mix(in srgb, var(--green) 14%, var(--card));
|
||||
border-color: var(--green);
|
||||
}
|
||||
.swatch.warn {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, var(--card));
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
.swatch.bad {
|
||||
background: color-mix(in srgb, var(--red) 14%, var(--card));
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/*
|
||||
* diagrams: inline svg inside figure.diagram, drawn with these classes so both schemes work
|
||||
* <g class="node [accent|dashed|good|bad|warn]"><rect …/><text …>Label</text><text class="sub" …>detail</text></g>
|
||||
* <path class="edge [back|good|bad|warn]" … marker-end="url(#arrow)"/>
|
||||
* <path class="arrow [back|good|bad|warn]" d="M0 0 L10 5 L0 10 z"/> inside a <marker>
|
||||
* <text class="edge-label [back|good|bad|warn]" …>label</text>
|
||||
* <rect class="group" …/> and <text class="group-label" …> a dashed box around nodes
|
||||
* for drawings with their own text placement (a data flow, a sequence), classes go on the shapes:
|
||||
* <rect class="box [info|good|warn|bad|accent] [strong] [dashed] [open]"/> a step, process or entity
|
||||
* <rect class="zone [info|good|warn|bad|accent] [boundary]"/> a tinted dashed area; boundary strokes it red
|
||||
* <rect class="panel"/> a quiet solid area behind a group of boxes
|
||||
* <rect class="chip [good|accent]"/> + <text class="chip-text"> a small filled badge or numbered dot
|
||||
* <path class="line"/> a plain stroke (a data store's rails) <line class="rule"/> a faint dashed guide
|
||||
* <rect class="fill"/> a borderless card-coloured shape: a data store's body, a backdrop
|
||||
* <text class="title|sub|mono|heading [info|good|warn|bad]"> text roles; text-anchor stays an attribute
|
||||
* .edge.dashed an answer or a nudge, not a main flow
|
||||
* figure.diagram.wide svg at least 1000px wide, scrolls sideways on a phone
|
||||
* an svg may also use currentColor and var(--good|--bad|--warn|--accent) in fill and stroke attributes
|
||||
*/
|
||||
figure {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
figure.diagram {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
color: var(--foreground);
|
||||
}
|
||||
figure.diagram svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-width: 640px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
figure.diagram.wide svg {
|
||||
min-width: 1000px;
|
||||
}
|
||||
figure.diagram text {
|
||||
fill: var(--foreground);
|
||||
}
|
||||
figcaption {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.node rect,
|
||||
.node circle,
|
||||
.node ellipse,
|
||||
.node polygon {
|
||||
fill: var(--card);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.node.accent rect,
|
||||
.node.accent circle,
|
||||
.node.accent polygon {
|
||||
fill: var(--accent-bg);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.node.dashed rect,
|
||||
.node.dashed circle,
|
||||
.node.dashed polygon {
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.node.good rect {
|
||||
fill: var(--good-bg);
|
||||
stroke: var(--good);
|
||||
}
|
||||
.node.bad rect {
|
||||
fill: var(--bad-bg);
|
||||
stroke: var(--bad);
|
||||
}
|
||||
.node.warn rect {
|
||||
fill: var(--warn-bg);
|
||||
stroke: var(--warn);
|
||||
}
|
||||
.node text {
|
||||
fill: var(--foreground);
|
||||
font: 500 14px var(--font-mono);
|
||||
text-anchor: middle;
|
||||
}
|
||||
.node text.sub {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
}
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.arrow {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.edge-label {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
text-anchor: middle;
|
||||
paint-order: stroke;
|
||||
stroke: var(--card);
|
||||
stroke-width: 4px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.edge.back {
|
||||
stroke: var(--accent);
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.arrow.back,
|
||||
.edge-label.back {
|
||||
fill: var(--accent);
|
||||
}
|
||||
.edge.good {
|
||||
stroke: var(--good);
|
||||
}
|
||||
.arrow.good,
|
||||
.edge-label.good {
|
||||
fill: var(--good);
|
||||
}
|
||||
.edge.bad {
|
||||
stroke: var(--bad);
|
||||
}
|
||||
.arrow.bad,
|
||||
.edge-label.bad {
|
||||
fill: var(--bad);
|
||||
}
|
||||
.edge.warn {
|
||||
stroke: var(--warn);
|
||||
}
|
||||
.arrow.warn,
|
||||
.edge-label.warn {
|
||||
fill: var(--warn);
|
||||
}
|
||||
.group {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-opacity: 0.5;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
.group-label {
|
||||
fill: var(--muted-foreground);
|
||||
font: 500 11px var(--font-mono);
|
||||
}
|
||||
|
||||
/* shapes with their own classes, for drawings that place their own text */
|
||||
|
||||
svg .box {
|
||||
fill: var(--card);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
svg .box.info {
|
||||
fill: color-mix(in srgb, var(--blue) 14%, var(--card));
|
||||
stroke: var(--blue);
|
||||
}
|
||||
svg .box.good {
|
||||
fill: color-mix(in srgb, var(--green) 14%, var(--card));
|
||||
stroke: var(--green);
|
||||
}
|
||||
svg .box.warn {
|
||||
fill: color-mix(in srgb, var(--yellow) 18%, var(--card));
|
||||
stroke: var(--yellow);
|
||||
}
|
||||
svg .box.bad {
|
||||
fill: color-mix(in srgb, var(--red) 14%, var(--card));
|
||||
stroke: var(--red);
|
||||
}
|
||||
svg .box.accent {
|
||||
fill: color-mix(in srgb, var(--orange) 14%, var(--card));
|
||||
stroke: var(--orange);
|
||||
}
|
||||
svg .box.strong {
|
||||
stroke: var(--foreground);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
svg .box.dashed,
|
||||
svg .edge.dashed {
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
svg .box.open {
|
||||
fill: none;
|
||||
}
|
||||
svg .zone {
|
||||
fill: color-mix(in srgb, var(--muted-foreground) 6%, transparent);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
svg .zone.info {
|
||||
fill: color-mix(in srgb, var(--blue) 7%, transparent);
|
||||
}
|
||||
svg .zone.good {
|
||||
fill: color-mix(in srgb, var(--green) 7%, transparent);
|
||||
}
|
||||
svg .zone.warn {
|
||||
fill: color-mix(in srgb, var(--yellow) 9%, transparent);
|
||||
}
|
||||
svg .zone.bad {
|
||||
fill: color-mix(in srgb, var(--red) 6%, transparent);
|
||||
}
|
||||
svg .zone.accent {
|
||||
fill: color-mix(in srgb, var(--orange) 7%, transparent);
|
||||
}
|
||||
svg .zone.boundary {
|
||||
stroke: var(--red);
|
||||
}
|
||||
svg .panel {
|
||||
fill: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
stroke: var(--border);
|
||||
}
|
||||
svg .chip {
|
||||
fill: var(--blue);
|
||||
}
|
||||
svg .chip.good {
|
||||
fill: var(--green);
|
||||
}
|
||||
svg .chip.accent {
|
||||
fill: var(--orange);
|
||||
}
|
||||
svg text.chip-text {
|
||||
fill: var(--background);
|
||||
font: 700 10px var(--font-mono);
|
||||
}
|
||||
svg .fill {
|
||||
fill: var(--card);
|
||||
stroke: none;
|
||||
}
|
||||
svg .line {
|
||||
fill: none;
|
||||
stroke: var(--foreground);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
svg .rule {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
svg text.title {
|
||||
fill: var(--foreground);
|
||||
font: 600 13px var(--font-mono);
|
||||
}
|
||||
svg text.sub {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
}
|
||||
svg text.mono {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 10.5px var(--font-mono);
|
||||
}
|
||||
svg text.heading {
|
||||
fill: var(--muted-foreground);
|
||||
font: 600 11px var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
svg text.info {
|
||||
fill: var(--info);
|
||||
}
|
||||
svg text.good {
|
||||
fill: var(--good);
|
||||
}
|
||||
svg text.warn {
|
||||
fill: var(--warn);
|
||||
}
|
||||
svg text.bad {
|
||||
fill: var(--bad);
|
||||
}
|
||||
|
||||
/* index */
|
||||
|
||||
.index-head h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.index-head p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.index h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
margin: 36px 0 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.index h2 .count {
|
||||
font: 500 12px var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.rows {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.rows li {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 9px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rows .id {
|
||||
font: 500 12px var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.rows .meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rows .title.superseded a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.rows.plain li {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.rows li {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
}
|
||||
.rows .meta {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* a page rendered from markdown */
|
||||
|
||||
.md-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.md-meta dt {
|
||||
display: inline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.md-meta dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
.md-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
.files {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* serve's hub: one row per project */
|
||||
|
||||
.rows.hub li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.rows.hub li {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.rows.hub .meta {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* serve's search */
|
||||
|
||||
form.search,
|
||||
input.filter {
|
||||
margin: 24px 0 0;
|
||||
}
|
||||
form.search input,
|
||||
input.filter {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--foreground);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 14px;
|
||||
}
|
||||
form.search input:focus,
|
||||
input.filter:focus {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.rows.results li {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
.rows.results .meta {
|
||||
display: none;
|
||||
}
|
||||
.snippet {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
mark {
|
||||
background: var(--warn-bg);
|
||||
color: inherit;
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
788
claude/skills/notes/notes.py
Executable file
788
claude/skills/notes/notes.py
Executable file
@@ -0,0 +1,788 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mechanics for a project's .notes/ dir, see ~/.config/claude/rules/notes.md
|
||||
|
||||
usage:
|
||||
notes.py [--root DIR] init create .notes/ with its git repo, if missing
|
||||
notes.py [--root DIR] next d|w|q print the next free id of a bucket
|
||||
notes.py [--root DIR] index regenerate index.html, notes.css, the markdown pages and
|
||||
the portable copies of other/out/ in _out/
|
||||
notes.py [--root DIR] commit SUBJECT regenerate the index, commit every change in .notes/
|
||||
notes.py serve [--port 8765] [--roots DIR ...]
|
||||
every project's notes on one port; roots default to
|
||||
$NOTES_ROOTS, else ~/git
|
||||
|
||||
DIR is the project root, by default the git toplevel of the current dir, else the current dir
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
BUCKETS = {"d": "docs", "w": "work", "q": "questions"}
|
||||
ID_RE = re.compile(r"^(docs|work|questions)/([dwq])(\d{3})-")
|
||||
README = (
|
||||
"Local notes, never pushed. Layout and rules: ~/.config/claude/rules/notes.md\n"
|
||||
)
|
||||
|
||||
|
||||
def git(notes: Path, *args: str, check: bool = True) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(notes), *args], check=check, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def project_root(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return Path(arg).resolve()
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
root = Path(out.stdout.strip()) if out.returncode == 0 else Path.cwd()
|
||||
# inside .notes the toplevel is the notes repo itself
|
||||
return root.parent if root.name == ".notes" else root
|
||||
|
||||
|
||||
def init(notes: Path) -> None:
|
||||
for d in ("docs", "work", "questions", "other/in", "other/out"):
|
||||
(notes / d).mkdir(parents=True, exist_ok=True)
|
||||
if not (notes / "README.md").exists():
|
||||
(notes / "README.md").write_text(README)
|
||||
if not (notes / ".gitignore").exists():
|
||||
(notes / ".gitignore").write_text("/index.html\n")
|
||||
if not (notes / ".git").exists():
|
||||
git(notes, "init", "-q", "-b", "main")
|
||||
# NOTE:(@janezicmatej) local-only repo, any identity works when none is configured
|
||||
if not git(notes, "config", "user.email", check=False).strip():
|
||||
git(notes, "config", "user.name", "notes")
|
||||
git(notes, "config", "user.email", "notes@localhost")
|
||||
commit(notes, "init notes")
|
||||
else:
|
||||
write_index(notes)
|
||||
print(notes)
|
||||
|
||||
|
||||
def next_id(notes: Path, letter: str) -> str:
|
||||
bucket = BUCKETS[letter]
|
||||
paths = [f"{bucket}/{p.name}" for p in (notes / bucket).glob("*")]
|
||||
if (notes / ".git").exists():
|
||||
log = git(notes, "log", "--all", "--name-only", "--pretty=format:", check=False)
|
||||
paths += log.splitlines()
|
||||
nums = [int(m[3]) for p in paths if (m := ID_RE.match(p)) and m[2] == letter]
|
||||
return f"{letter}{max(nums, default=0) + 1:03d}"
|
||||
|
||||
|
||||
def commit(notes: Path, subject: str) -> None:
|
||||
write_index(notes)
|
||||
git(notes, "add", "-A")
|
||||
if git(notes, "status", "--porcelain").strip():
|
||||
git(notes, "commit", "-q", "--no-gpg-sign", "-m", subject)
|
||||
print(git(notes, "log", "-1", "--oneline").strip())
|
||||
else:
|
||||
print("nothing to commit")
|
||||
|
||||
|
||||
# pages: index.html, notes.css, _pages/ and _out/ are generated, never committed
|
||||
|
||||
CSS = Path(__file__).with_name("notes.css")
|
||||
GENERATED = ("/index.html", "/notes.css", "/_pages/", "/_out/")
|
||||
PAGES = "_pages"
|
||||
OUT = "_out"
|
||||
CSS_LINK = re.compile(r'<link\b[^>]*href="[^"]*notes\.css"[^>]*>', re.IGNORECASE)
|
||||
BACK_NAV = re.compile(r'\s*<nav class="back">.*?</nav>', re.DOTALL | re.IGNORECASE)
|
||||
ID_TOKEN = re.compile(r"(?<![\w/.-])([dwq]\d{3})(?![\w/-])")
|
||||
|
||||
|
||||
def ensure_gitignore(notes: Path) -> None:
|
||||
path = notes / ".gitignore"
|
||||
lines = path.read_text().splitlines() if path.exists() else []
|
||||
missing = [g for g in GENERATED if g not in lines]
|
||||
if missing:
|
||||
path.write_text("\n".join([*lines, *missing]) + "\n")
|
||||
|
||||
|
||||
def frontmatter(text: str) -> tuple[dict[str, str], str]:
|
||||
m = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
|
||||
if not m:
|
||||
return {}, text
|
||||
meta = {}
|
||||
for line in m[1].splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta, text[m.end() :]
|
||||
|
||||
|
||||
def split_title(body: str, fallback: str) -> tuple[str, str]:
|
||||
"""first heading as the title, without a leading id, and the body without it"""
|
||||
m = re.search(r"^#\s+(.+)\n?", body, re.MULTILINE)
|
||||
if not m:
|
||||
return fallback, body
|
||||
title = re.sub(r"^[dwq]\d{3}\b\s*", "", m[1].strip())
|
||||
return title, body[: m.start()] + body[m.end() :]
|
||||
|
||||
|
||||
def strip_tags(s: str) -> str:
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
|
||||
|
||||
|
||||
class Site:
|
||||
"""what exists in .notes/ and where each item's page is, relative to .notes/"""
|
||||
|
||||
def __init__(self, notes: Path) -> None:
|
||||
self.notes = notes
|
||||
self.docs = self._docs()
|
||||
self.work = sorted(p for p in (notes / "work").glob("w[0-9]*") if p.is_dir())
|
||||
self.questions = sorted((notes / "questions").glob("q[0-9]*.md"))
|
||||
self.other = {
|
||||
sub: sorted(f for f in (notes / "other" / sub).rglob("*") if f.is_file())
|
||||
for sub in ("in", "out")
|
||||
if (notes / "other" / sub).exists()
|
||||
}
|
||||
self.urls: dict[str, str] = {}
|
||||
for d in self.docs:
|
||||
self.urls[d["id"]] = d["page"]
|
||||
for w in self.work:
|
||||
self.urls[w.name[:4]] = self.page_of(w / "brief.md")
|
||||
for q in self.questions:
|
||||
self.urls[q.name[:4]] = self.page_of(q)
|
||||
|
||||
def _docs(self) -> list[dict]:
|
||||
out = []
|
||||
for p in sorted((self.notes / "docs").glob("d[0-9]*")):
|
||||
page = p / "index.html" if p.is_dir() else p
|
||||
if not page.is_file() or page.suffix != ".html":
|
||||
continue
|
||||
text = page.read_text(errors="replace")
|
||||
title = re.search(r"<title>(.*?)</title>", text, re.DOTALL | re.IGNORECASE)
|
||||
status = re.search(
|
||||
r"<p[^>]*class=\"[^\"]*\bstatus\b[^\"]*\"[^>]*>(.*?)</p>",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
status_text = re.sub(
|
||||
r"^status:\s*", "", strip_tags(status[1]) if status else ""
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": p.name[:4],
|
||||
"title": strip_tags(title[1]) if title else p.stem,
|
||||
"page": page.relative_to(self.notes).as_posix(),
|
||||
"status": status_text,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def page_of(self, src: Path) -> str:
|
||||
"""where a markdown file's rendered page lives, relative to .notes/"""
|
||||
rel = src.relative_to(self.notes).with_suffix(".html")
|
||||
return f"{PAGES}/{rel.as_posix()}"
|
||||
|
||||
def href(self, target: str, from_page: str) -> str:
|
||||
"""a link from one page to another, both relative to .notes/"""
|
||||
base = Path(from_page).parent
|
||||
return quote(Path(*([".."] * len(base.parts)), target).as_posix())
|
||||
|
||||
|
||||
def status_tag(status: str) -> str:
|
||||
e = html.escape
|
||||
if status.startswith("current"):
|
||||
return f'<span class="tag good" title="{e(status)}">current</span>'
|
||||
if status.startswith("partly stale"):
|
||||
return f'<span class="tag warn" title="{e(status)}">stale</span>'
|
||||
if m := re.match(r"superseded by (d\d{3})", status):
|
||||
return f'<span class="tag" title="{e(status)}">superseded by {m[1]}</span>'
|
||||
return '<span class="tag bad">no status line</span>'
|
||||
|
||||
|
||||
def page(site: Site, path: str, title: str, body: str) -> str:
|
||||
css = site.href("notes.css", path)
|
||||
return (
|
||||
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
f"<title>{html.escape(title)}</title>\n"
|
||||
f'<link rel="stylesheet" href="{css}">\n</head>\n'
|
||||
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
||||
)
|
||||
|
||||
|
||||
# markdown, the small subset notes are written in
|
||||
|
||||
|
||||
def inline(text: str, site: Site, at: str) -> str:
|
||||
out, last = [], 0
|
||||
for m in re.finditer(r"`([^`]+)`", text):
|
||||
out.append(inline_plain(text[last : m.start()], site, at))
|
||||
out.append(f"<code>{html.escape(m[1])}</code>")
|
||||
last = m.end()
|
||||
out.append(inline_plain(text[last:], site, at))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def inline_plain(text: str, site: Site, at: str) -> str:
|
||||
links: list[str] = []
|
||||
|
||||
def keep(s: str) -> str:
|
||||
links.append(s)
|
||||
return f"\x00{len(links) - 1}\x00"
|
||||
|
||||
text = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||
lambda m: keep(f'<a href="{html.escape(m[2])}">{html.escape(m[1])}</a>'),
|
||||
text,
|
||||
)
|
||||
text = html.escape(text, quote=False)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
|
||||
def id_link(m: re.Match) -> str:
|
||||
url = site.urls.get(m[1])
|
||||
return f'<a href="{site.href(url, at)}">{m[1]}</a>' if url else m[1]
|
||||
|
||||
text = ID_TOKEN.sub(id_link, text)
|
||||
return re.sub(r"\x00(\d+)\x00", lambda m: links[int(m[1])], text)
|
||||
|
||||
|
||||
def markdown(text: str, site: Site, at: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
para: list[str] = []
|
||||
# open lists as (indent, tag)
|
||||
lists: list[tuple[int, str]] = []
|
||||
item_open = False
|
||||
|
||||
def flush_para() -> None:
|
||||
if para:
|
||||
out.append(f"<p>{inline(' '.join(para), site, at)}</p>")
|
||||
para.clear()
|
||||
|
||||
def close_lists(to: int = -1) -> None:
|
||||
nonlocal item_open
|
||||
while lists and lists[-1][0] > to:
|
||||
out.append(f"</li></{lists.pop()[1]}>")
|
||||
item_open = bool(lists)
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.lstrip().startswith("```"):
|
||||
flush_para()
|
||||
close_lists()
|
||||
indent = len(line) - len(line.lstrip())
|
||||
code = []
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].lstrip().startswith("```"):
|
||||
code.append(lines[i][indent:])
|
||||
i += 1
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
|
||||
i += 1
|
||||
continue
|
||||
if not line.strip():
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
if m := re.match(r"^(#{1,4})\s+(.*)$", line):
|
||||
flush_para()
|
||||
close_lists()
|
||||
level = min(len(m[1]) + 1, 4)
|
||||
out.append(f"<h{level}>{inline(m[2], site, at)}</h{level}>")
|
||||
i += 1
|
||||
continue
|
||||
if m := re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line):
|
||||
flush_para()
|
||||
indent, tag = len(m[1]), "ol" if m[2][0].isdigit() else "ul"
|
||||
if not lists or indent > lists[-1][0]:
|
||||
out.append(f"<{tag}><li>")
|
||||
lists.append((indent, tag))
|
||||
else:
|
||||
close_lists(indent)
|
||||
if lists and lists[-1][0] == indent:
|
||||
out.append("</li><li>")
|
||||
else:
|
||||
out.append(f"<{tag}><li>")
|
||||
lists.append((indent, tag))
|
||||
item = [m[3]]
|
||||
i += 1
|
||||
# continuation lines belong to the item
|
||||
while (
|
||||
i < len(lines)
|
||||
and lines[i].strip()
|
||||
and not re.match(r"^\s*([-*]|\d+\.)\s+|^\s*```|^#{1,4}\s", lines[i])
|
||||
):
|
||||
item.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append(inline(" ".join(item), site, at))
|
||||
continue
|
||||
if lists and line.startswith(" "):
|
||||
out.append(" " + inline(line.strip(), site, at))
|
||||
i += 1
|
||||
continue
|
||||
close_lists()
|
||||
para.append(line.strip())
|
||||
i += 1
|
||||
flush_para()
|
||||
close_lists()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_md(site: Site, src: Path, kind: str) -> None:
|
||||
at = site.page_of(src)
|
||||
meta, body = frontmatter(src.read_text(errors="replace"))
|
||||
title, body = split_title(body, src.stem)
|
||||
e = html.escape
|
||||
item_id = next((p[:4] for p in src.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
||||
eyebrow = " · ".join(x for x in (item_id, kind) if x)
|
||||
parts = [
|
||||
f'<nav class="back"><a href="{site.href("index.html", at)}">← notes</a></nav>',
|
||||
"<header>",
|
||||
f'<p class="eyebrow">{e(eyebrow)}</p>' if eyebrow else "",
|
||||
f"<h1>{e(title)}</h1>",
|
||||
]
|
||||
if meta:
|
||||
dl = "".join(
|
||||
f"<div><dt>{e(k)}</dt><dd>{inline(v, site, at)}</dd></div>"
|
||||
for k, v in meta.items()
|
||||
)
|
||||
parts.append(f'<dl class="md-meta">{dl}</dl>')
|
||||
parts += ["</header>", markdown(body, site, at)]
|
||||
if kind == "work":
|
||||
files = []
|
||||
for f in sorted(src.parent.rglob("*")):
|
||||
if not f.is_file() or f == src:
|
||||
continue
|
||||
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(site.notes)
|
||||
files.append(f'<a href="{site.href(str(target), at)}">{e(f.name)}</a>')
|
||||
if files:
|
||||
parts.append(f'<p class="files">files: {" · ".join(files)}</p>')
|
||||
out = site.notes / at
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(page(site, at, title, "\n".join(p for p in parts if p)))
|
||||
|
||||
|
||||
def clear(d: Path) -> None:
|
||||
if d.exists():
|
||||
for f in sorted(d.rglob("*"), reverse=True):
|
||||
f.unlink() if f.is_file() else f.rmdir()
|
||||
|
||||
|
||||
def write_portable(site: Site) -> None:
|
||||
"""other/out/ as it leaves .notes/: css inlined, no back link to an index it won't have"""
|
||||
out = site.notes / OUT
|
||||
clear(out)
|
||||
src = site.notes / "other" / "out"
|
||||
style = f"<style>\n{CSS.read_text()}</style>"
|
||||
for f in site.other.get("out", []):
|
||||
target = out / f.relative_to(src)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if f.suffix != ".html":
|
||||
target.write_bytes(f.read_bytes())
|
||||
continue
|
||||
# the nav goes before the css comes in: its header comment shows a nav too
|
||||
text = BACK_NAV.sub("", f.read_text(errors="replace"), count=1)
|
||||
target.write_text(CSS_LINK.sub(lambda _: style, text, count=1))
|
||||
|
||||
|
||||
def write_index(notes: Path) -> None:
|
||||
ensure_gitignore(notes)
|
||||
(notes / "notes.css").write_text(CSS.read_text())
|
||||
site = Site(notes)
|
||||
|
||||
clear(notes / PAGES)
|
||||
write_portable(site)
|
||||
for q in site.questions:
|
||||
render_md(site, q, "question")
|
||||
for w in site.work:
|
||||
for f in sorted(w.rglob("*.md")):
|
||||
render_md(site, f, "work")
|
||||
for files in site.other.values():
|
||||
for f in files:
|
||||
if f.suffix == ".md":
|
||||
render_md(site, f, "other")
|
||||
|
||||
e = html.escape
|
||||
|
||||
def section(name: str, rows: list[str], empty: str, plain: bool = False) -> str:
|
||||
head = f'<h2>{name} <span class="count">{len(rows)}</span></h2>'
|
||||
if not rows:
|
||||
return f'{head}<p class="empty">{empty}</p>'
|
||||
cls = "rows plain" if plain else "rows"
|
||||
return f'{head}<ul class="{cls}">{"".join(rows)}</ul>'
|
||||
|
||||
def row(item_id: str, href: str, title: str, meta: str = "", cls: str = "") -> str:
|
||||
return (
|
||||
f'<li><span class="id">{e(item_id)}</span>'
|
||||
f'<span class="title {cls}"><a href="{href}">{e(title)}</a></span>'
|
||||
f'<span class="meta">{meta}</span></li>'
|
||||
)
|
||||
|
||||
docs = [
|
||||
row(
|
||||
d["id"],
|
||||
quote(d["page"]),
|
||||
d["title"],
|
||||
status_tag(d["status"]),
|
||||
"superseded" if d["status"].startswith("superseded") else "",
|
||||
)
|
||||
for d in site.docs
|
||||
]
|
||||
work = []
|
||||
for w in site.work:
|
||||
brief = w / "brief.md"
|
||||
title = w.name[5:]
|
||||
if brief.exists():
|
||||
title, _ = split_title(frontmatter(brief.read_text())[1], title)
|
||||
work.append(row(w.name[:4], quote(site.urls[w.name[:4]]), title))
|
||||
questions = []
|
||||
for q in site.questions:
|
||||
meta, body = frontmatter(q.read_text(errors="replace"))
|
||||
title, _ = split_title(body, q.stem)
|
||||
created = e(meta.get("created", ""))
|
||||
questions.append(row(q.name[:4], quote(site.page_of(q)), title, created))
|
||||
|
||||
other = []
|
||||
for sub, files in site.other.items():
|
||||
for f in files:
|
||||
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(notes)
|
||||
portable = ""
|
||||
if sub == "out" and f.suffix == ".html":
|
||||
rel = f.relative_to(notes / "other" / "out").as_posix()
|
||||
portable = (
|
||||
f' <a class="tag info" href="{quote(f"{OUT}/{rel}")}">to send</a>'
|
||||
)
|
||||
other.append(
|
||||
f'<li><span class="title"><a href="{quote(str(target))}">{e(f.name)}</a>'
|
||||
f' <span class="tag">{sub}</span>{portable}</span></li>'
|
||||
)
|
||||
|
||||
project = notes.parent.name
|
||||
body = "\n".join(
|
||||
[
|
||||
# under serve this is the hub, under a plain http.server it is this index
|
||||
'<nav class="back"><a href="/">← all notes</a></nav>',
|
||||
'<header class="index-head">',
|
||||
f"<h1>{e(project)} notes</h1>",
|
||||
(
|
||||
f"<p>{len(site.docs)} docs · {len(site.work)} in flight · "
|
||||
f"{len(site.questions)} open questions · generated {date.today()}</p>"
|
||||
),
|
||||
"</header>",
|
||||
'<div class="index">',
|
||||
section("Docs", docs, "no docs"),
|
||||
section("Work in flight", work, "nothing in flight"),
|
||||
section("Open questions", questions, "no open questions"),
|
||||
section("Other", other, "nothing here", plain=True) if other else "",
|
||||
"</div>",
|
||||
]
|
||||
)
|
||||
(notes / "index.html").write_text(
|
||||
page(site, "index.html", f"{project} notes", body)
|
||||
)
|
||||
|
||||
|
||||
# serve: every project's notes on one port
|
||||
|
||||
|
||||
SKIP_DIRS = {"node_modules", "target", "dist", "build", "vendor", "__pycache__"}
|
||||
|
||||
|
||||
def find_notes(roots: list[Path], depth: int = 6) -> dict[str, Path]:
|
||||
"""url prefix (path relative to its root) -> .notes dir, for every project under roots"""
|
||||
found: dict[str, Path] = {}
|
||||
|
||||
def walk(d: Path, root: Path, left: int) -> None:
|
||||
notes = d / ".notes"
|
||||
if notes.is_dir() and (notes / "docs").is_dir():
|
||||
found[d.relative_to(root).as_posix() or d.name] = notes
|
||||
# a repo's own subdirectories are its code, not more projects
|
||||
if left == 0 or ((d / ".git").exists() and d != root):
|
||||
return
|
||||
try:
|
||||
children = sorted(d.iterdir())
|
||||
except OSError:
|
||||
return
|
||||
for c in children:
|
||||
skip = c.name.startswith(".") or c.name in SKIP_DIRS
|
||||
if c.is_dir() and not c.is_symlink() and not skip:
|
||||
walk(c, root, left - 1)
|
||||
|
||||
for root in roots:
|
||||
if root.is_dir():
|
||||
walk(root, root, depth)
|
||||
return found
|
||||
|
||||
|
||||
def hub_page(title: str, body: str) -> str:
|
||||
return (
|
||||
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
f"<title>{html.escape(title)}</title>\n"
|
||||
'<link rel="stylesheet" href="/notes.css">\n</head>\n'
|
||||
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
||||
)
|
||||
|
||||
|
||||
def search_form(action: str, q: str = "") -> str:
|
||||
return (
|
||||
f'<form class="search" action="{action}" role="search">'
|
||||
f'<input type="search" name="q" value="{html.escape(q)}" '
|
||||
'placeholder="search this project\'s notes" aria-label="search notes">'
|
||||
"</form>"
|
||||
)
|
||||
|
||||
|
||||
# filters the hub's project rows as you type, nothing leaves the page
|
||||
FILTER_BOX = (
|
||||
'<input class="filter" type="search" placeholder="filter projects" '
|
||||
'aria-label="filter projects" autofocus>'
|
||||
)
|
||||
FILTER_SCRIPT = """<script>
|
||||
const box = document.querySelector("input.filter");
|
||||
const rows = [...document.querySelectorAll(".rows.hub li")];
|
||||
box.addEventListener("input", () => {
|
||||
const words = box.value.toLowerCase().split(/\\s+/).filter(Boolean);
|
||||
for (const r of rows) {
|
||||
const name = r.dataset.name;
|
||||
r.hidden = !words.every((w) => name.includes(w));
|
||||
}
|
||||
document.querySelector(".hub-empty").hidden = rows.some((r) => !r.hidden);
|
||||
});
|
||||
box.addEventListener("keydown", (e) => {
|
||||
const first = rows.find((r) => !r.hidden);
|
||||
if (e.key === "Enter" && first) location.href = first.querySelector("a").href;
|
||||
});
|
||||
</script>"""
|
||||
|
||||
|
||||
def hub(projects: dict[str, Path]) -> str:
|
||||
e = html.escape
|
||||
rows = []
|
||||
for prefix, notes in sorted(projects.items()):
|
||||
questions = len(list((notes / "questions").glob("q[0-9]*.md")))
|
||||
work = len([p for p in (notes / "work").glob("w[0-9]*") if p.is_dir()])
|
||||
docs = len(list((notes / "docs").glob("d[0-9]*")))
|
||||
last = git(notes, "log", "-1", "--format=%cs", check=False).strip()
|
||||
meta = f"{docs} docs · {work} in flight · {questions} open · {last or 'no commits'}"
|
||||
rows.append(
|
||||
f'<li data-name="{e(prefix.lower())}">'
|
||||
f'<span class="title"><a href="/{quote(prefix)}/">{e(prefix)}</a></span>'
|
||||
f'<span class="meta">{meta}</span></li>'
|
||||
)
|
||||
body = (
|
||||
'<header class="index-head"><h1>notes</h1>'
|
||||
f"<p>{len(projects)} projects · generated {date.today()}</p></header>"
|
||||
+ (FILTER_BOX if rows else "")
|
||||
+ '<div class="index"><h2>Projects</h2>'
|
||||
+ (
|
||||
f'<ul class="rows hub">{"".join(rows)}</ul>'
|
||||
'<p class="empty hub-empty" hidden>no project matches</p>'
|
||||
if rows
|
||||
else '<p class="empty">no .notes/ found</p>'
|
||||
)
|
||||
+ "</div>"
|
||||
# after the rows, so the script finds them
|
||||
+ (FILTER_SCRIPT if rows else "")
|
||||
)
|
||||
return hub_page("notes", body)
|
||||
|
||||
|
||||
def page_text(page: str) -> str:
|
||||
"""the readable text of an html page, without its chrome, words kept apart"""
|
||||
flags = re.DOTALL | re.IGNORECASE
|
||||
page = re.sub(r"<(style|script|svg|nav|title)\b.*?</\1>", " ", page, flags=flags)
|
||||
return html.unescape(re.sub(r"<[^>]+>", " ", page))
|
||||
|
||||
|
||||
def searchable(notes: Path) -> list[tuple[str, str, str, str]]:
|
||||
"""(id, title, url relative to .notes/, plain text) for every page worth searching"""
|
||||
site = Site(notes)
|
||||
out = []
|
||||
for d in site.docs:
|
||||
text = (notes / d["page"]).read_text(errors="replace")
|
||||
out.append((d["id"], d["title"], d["page"], page_text(text)))
|
||||
mds = [*site.questions, *(f for w in site.work for f in sorted(w.rglob("*.md")))]
|
||||
mds += [f for files in site.other.values() for f in files if f.suffix == ".md"]
|
||||
for f in mds:
|
||||
_, body = frontmatter(f.read_text(errors="replace"))
|
||||
title, _ = split_title(body, f.stem)
|
||||
item = next((p[:4] for p in f.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
||||
out.append((item, title, site.page_of(f), body))
|
||||
for files in site.other.values():
|
||||
for f in files:
|
||||
if f.suffix == ".html":
|
||||
text = page_text(f.read_text(errors="replace"))
|
||||
out.append(("", f.name, f.relative_to(notes).as_posix(), text))
|
||||
return out
|
||||
|
||||
|
||||
def search(prefix: str, notes: Path, q: str) -> str:
|
||||
e = html.escape
|
||||
terms = [t.lower() for t in q.split() if t]
|
||||
hits = []
|
||||
for item, title, url, text in searchable(notes) if terms else []:
|
||||
low_title, low = title.lower(), " ".join(text.split()).lower()
|
||||
if not all(t in low_title or t in low for t in terms):
|
||||
continue
|
||||
flat = " ".join(text.split())
|
||||
# centre on the whole phrase if it occurs, else on the rarest word
|
||||
at = low.find(" ".join(terms))
|
||||
if at < 0:
|
||||
rarest = min((t for t in terms if t in low), key=low.count, default="")
|
||||
at = max(low.find(rarest), 0) if rarest else 0
|
||||
start = max(0, at - 80)
|
||||
snippet = e(("…" if start else "") + flat[start : at + 160] + "…")
|
||||
for t in sorted(set(terms), key=len, reverse=True):
|
||||
snippet = re.sub(
|
||||
f"({re.escape(e(t))})", r"<mark>\1</mark>", snippet, flags=re.IGNORECASE
|
||||
)
|
||||
score = sum(t in low_title for t in terms) * 100 + sum(
|
||||
low.count(t) for t in terms
|
||||
)
|
||||
hits.append((score, item, title, url, snippet))
|
||||
write_index(notes)
|
||||
hits.sort(key=lambda h: -h[0])
|
||||
rows = "".join(
|
||||
f'<li><span class="id">{e(item)}</span><span class="title">'
|
||||
f'<a href="{quote(url)}">{e(title)}</a>'
|
||||
f'<span class="snippet">{snippet}</span></span></li>'
|
||||
for _, item, title, url, snippet in hits
|
||||
)
|
||||
found = (
|
||||
f'<ul class="rows results">{rows}</ul>'
|
||||
if rows
|
||||
else '<p class="empty">nothing found</p>'
|
||||
)
|
||||
body = (
|
||||
'<nav class="back"><a href="./">← notes</a></nav>'
|
||||
f'<header class="index-head"><h1>{e(prefix)}: search</h1>'
|
||||
f"<p>{len(hits)} results for “{e(q)}”</p></header>"
|
||||
+ search_form("search", q)
|
||||
+ f'<div class="index">{found}</div>'
|
||||
)
|
||||
return hub_page(f"{q} · {prefix} notes", body)
|
||||
|
||||
|
||||
def serve(roots: list[Path], host: str, port: int) -> None:
|
||||
import http.server
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def guess_type(self, path: str) -> str:
|
||||
if str(path).endswith(".md"):
|
||||
return "text/plain; charset=utf-8"
|
||||
return super().guess_type(path)
|
||||
|
||||
def send_text(self, body: str, ctype: str) -> None:
|
||||
data = body.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = unquote(urlsplit(self.path).path)
|
||||
if path in ("/", "/index.html"):
|
||||
return self.send_text(
|
||||
hub(find_notes(roots)), "text/html; charset=utf-8"
|
||||
)
|
||||
if path == "/notes.css":
|
||||
return self.send_text(CSS.read_text(), "text/css; charset=utf-8")
|
||||
projects = find_notes(roots)
|
||||
prefix = max(
|
||||
(p for p in projects if path == f"/{p}" or path.startswith(f"/{p}/")),
|
||||
key=len,
|
||||
default=None,
|
||||
)
|
||||
if prefix is None:
|
||||
return self.send_error(404, "no such project")
|
||||
if path == f"/{prefix}":
|
||||
self.send_response(301)
|
||||
self.send_header("Location", f"/{quote(prefix)}/")
|
||||
self.end_headers()
|
||||
return None
|
||||
rest = path[len(prefix) + 2 :]
|
||||
notes = projects[prefix]
|
||||
if rest == "search":
|
||||
q = parse_qs(urlsplit(self.path).query).get("q", [""])[0]
|
||||
body = search(prefix, notes, q)
|
||||
return self.send_text(body, "text/html; charset=utf-8")
|
||||
if rest in ("", "index.html"):
|
||||
# rebuilt on every visit, so a session that forgot to regenerate never shows stale;
|
||||
# the search box only works under serve, so it is added here, not in the file
|
||||
write_index(notes)
|
||||
page = (notes / "index.html").read_text()
|
||||
page = page.replace("</header>", "</header>" + search_form("search"), 1)
|
||||
return self.send_text(page, "text/html; charset=utf-8")
|
||||
self.serve_from = projects[prefix]
|
||||
self.path = "/" + rest
|
||||
return super().do_GET()
|
||||
|
||||
def translate_path(self, path: str) -> str:
|
||||
base = self.serve_from.resolve()
|
||||
target = (base / unquote(urlsplit(path).path).lstrip("/")).resolve()
|
||||
return str(target if target.is_relative_to(base) else base / "__outside__")
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
||||
print(f"serving notes from {', '.join(map(str, roots))} on http://{host}:{port}/")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def default_roots() -> list[Path]:
|
||||
import os
|
||||
|
||||
env = os.environ.get("NOTES_ROOTS")
|
||||
if env:
|
||||
return [Path(p).expanduser() for p in env.split(os.pathsep) if p]
|
||||
return [Path.home() / "git"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--root")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("init")
|
||||
p_next = sub.add_parser("next")
|
||||
p_next.add_argument("letter", choices=sorted(BUCKETS))
|
||||
sub.add_parser("index")
|
||||
p_commit = sub.add_parser("commit")
|
||||
p_commit.add_argument("subject")
|
||||
p_serve = sub.add_parser("serve")
|
||||
p_serve.add_argument("--port", type=int, default=8765)
|
||||
p_serve.add_argument("--host", default="127.0.0.1")
|
||||
p_serve.add_argument("--roots", nargs="*", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "serve":
|
||||
serve(args.roots or default_roots(), args.host, args.port)
|
||||
return
|
||||
|
||||
notes = project_root(args.root) / ".notes"
|
||||
if args.cmd == "init":
|
||||
init(notes)
|
||||
return
|
||||
if not notes.exists():
|
||||
sys.exit(f"{notes} does not exist, run init first")
|
||||
if args.cmd == "next":
|
||||
print(next_id(notes, args.letter))
|
||||
elif args.cmd == "index":
|
||||
write_index(notes)
|
||||
print(notes / "index.html")
|
||||
elif args.cmd == "commit":
|
||||
commit(notes, args.subject)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user