@praxisflux/gates 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/codebase-to-course/gates/cli.mjs +20 -0
- package/codebase-to-course/gates/course.mjs +58 -0
- package/codebase-to-course/lib/README.md +8 -0
- package/codebase-to-course/lib/cli.mjs +23 -0
- package/codebase-to-course/lib/dates.mjs +11 -0
- package/codebase-to-course/lib/gate-runner.mjs +69 -0
- package/codebase-to-course/lib/handoff.mjs +75 -0
- package/codebase-to-course/lib/html/base.html +129 -0
- package/codebase-to-course/lib/installer.mjs +52 -0
- package/codebase-to-course/lib/lifecycle.mjs +67 -0
- package/codebase-to-course/lib/markdown.mjs +81 -0
- package/codebase-to-course/lib/project-root.mjs +52 -0
- package/codebase-to-course/lib/selfcontained.mjs +37 -0
- package/codebase-to-course/lib/spec-derive.mjs +114 -0
- package/codebase-to-course/lib/template.mjs +11 -0
- package/codebase-to-course/lib/toolkit/README.md +45 -0
- package/codebase-to-course/lib/toolkit/code-translation.md +50 -0
- package/codebase-to-course/lib/toolkit/diagrams.md +57 -0
- package/codebase-to-course/lib/toolkit/pedagogy.md +39 -0
- package/codebase-to-course/lib/toolkit/quiz-patterns.md +65 -0
- package/codebase-to-course/lib/toolkit/svg-diagrams.md +59 -0
- package/codebase-to-course/lib/toolkit/tooltip.md +83 -0
- package/codebase-to-course/skills/codebase-to-course/references/validate.mjs +301 -0
- package/grounding-wiki/gates/cli.mjs +26 -0
- package/grounding-wiki/gates/freshness.mjs +92 -0
- package/grounding-wiki/lib/README.md +8 -0
- package/grounding-wiki/lib/cli.mjs +23 -0
- package/grounding-wiki/lib/dates.mjs +11 -0
- package/grounding-wiki/lib/gate-runner.mjs +69 -0
- package/grounding-wiki/lib/handoff.mjs +75 -0
- package/grounding-wiki/lib/html/base.html +129 -0
- package/grounding-wiki/lib/installer.mjs +52 -0
- package/grounding-wiki/lib/lifecycle.mjs +67 -0
- package/grounding-wiki/lib/markdown.mjs +81 -0
- package/grounding-wiki/lib/project-root.mjs +52 -0
- package/grounding-wiki/lib/selfcontained.mjs +37 -0
- package/grounding-wiki/lib/spec-derive.mjs +114 -0
- package/grounding-wiki/lib/template.mjs +11 -0
- package/grounding-wiki/lib/toolkit/README.md +45 -0
- package/grounding-wiki/lib/toolkit/code-translation.md +50 -0
- package/grounding-wiki/lib/toolkit/diagrams.md +57 -0
- package/grounding-wiki/lib/toolkit/pedagogy.md +39 -0
- package/grounding-wiki/lib/toolkit/quiz-patterns.md +65 -0
- package/grounding-wiki/lib/toolkit/svg-diagrams.md +59 -0
- package/grounding-wiki/lib/toolkit/tooltip.md +83 -0
- package/lib/README.md +8 -0
- package/lib/cli.mjs +23 -0
- package/lib/dates.mjs +11 -0
- package/lib/gate-runner.mjs +69 -0
- package/lib/handoff.mjs +75 -0
- package/lib/html/base.html +129 -0
- package/lib/installer.mjs +52 -0
- package/lib/lifecycle.mjs +67 -0
- package/lib/markdown.mjs +81 -0
- package/lib/project-root.mjs +52 -0
- package/lib/selfcontained.mjs +37 -0
- package/lib/spec-derive.mjs +114 -0
- package/lib/template.mjs +11 -0
- package/lib/toolkit/README.md +45 -0
- package/lib/toolkit/code-translation.md +50 -0
- package/lib/toolkit/diagrams.md +57 -0
- package/lib/toolkit/pedagogy.md +39 -0
- package/lib/toolkit/quiz-patterns.md +65 -0
- package/lib/toolkit/svg-diagrams.md +59 -0
- package/lib/toolkit/tooltip.md +83 -0
- package/package.json +17 -0
- package/scripts/run-gates.mjs +91 -0
- package/spec-bridge/gates/bridge.mjs +130 -0
- package/spec-bridge/gates/cli.mjs +37 -0
- package/spec-bridge/lib/README.md +8 -0
- package/spec-bridge/lib/cli.mjs +23 -0
- package/spec-bridge/lib/dates.mjs +11 -0
- package/spec-bridge/lib/gate-runner.mjs +69 -0
- package/spec-bridge/lib/handoff.mjs +75 -0
- package/spec-bridge/lib/html/base.html +129 -0
- package/spec-bridge/lib/installer.mjs +52 -0
- package/spec-bridge/lib/lifecycle.mjs +67 -0
- package/spec-bridge/lib/markdown.mjs +81 -0
- package/spec-bridge/lib/project-root.mjs +52 -0
- package/spec-bridge/lib/selfcontained.mjs +37 -0
- package/spec-bridge/lib/spec-derive.mjs +114 -0
- package/spec-bridge/lib/template.mjs +11 -0
- package/spec-bridge/lib/toolkit/README.md +45 -0
- package/spec-bridge/lib/toolkit/code-translation.md +50 -0
- package/spec-bridge/lib/toolkit/diagrams.md +57 -0
- package/spec-bridge/lib/toolkit/pedagogy.md +39 -0
- package/spec-bridge/lib/toolkit/quiz-patterns.md +65 -0
- package/spec-bridge/lib/toolkit/svg-diagrams.md +59 -0
- package/spec-bridge/lib/toolkit/tooltip.md +83 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# HTML/CSS diagram idioms
|
|
2
|
+
|
|
3
|
+
Diagrams that are plain HTML + CSS (boxes, arrows, trees — often with an emoji as the icon)
|
|
4
|
+
inherit the theme tokens, wrap responsively, and never hit SVG's text pitfalls. This is
|
|
5
|
+
codebase-to-course's native diagram idiom, portable to any praxis surface. For hand-drawn
|
|
6
|
+
SVG figures instead, see `svg-diagrams.md`.
|
|
7
|
+
|
|
8
|
+
**Graceful degradation:** if this module isn't available, a numbered list of steps or an
|
|
9
|
+
indented mono file listing does the job.
|
|
10
|
+
|
|
11
|
+
## Flow of steps (boxes with arrow separators)
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<style>
|
|
15
|
+
.flow{display:flex;align-items:stretch;gap:0;flex-wrap:wrap}
|
|
16
|
+
.flow .step{flex:1;min-width:9em;background:var(--card);border:1px solid var(--line);
|
|
17
|
+
border-radius:12px;padding:.8em 1em;text-align:center}
|
|
18
|
+
.flow .step b{display:block;margin-bottom:.25em}
|
|
19
|
+
.flow .arrow{align-self:center;padding:0 .5em;color:var(--muted);font-size:1.4em}
|
|
20
|
+
</style>
|
|
21
|
+
|
|
22
|
+
<div class="flow">
|
|
23
|
+
<div class="step">📨 <b>Input</b><span class="muted">the click arrives</span></div>
|
|
24
|
+
<div class="arrow">→</div>
|
|
25
|
+
<div class="step">⚙️ <b>Process</b><span class="muted">the server decides</span></div>
|
|
26
|
+
<div class="arrow">→</div>
|
|
27
|
+
<div class="step">📬 <b>Output</b><span class="muted">the page updates</span></div>
|
|
28
|
+
</div>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Annotated file tree
|
|
32
|
+
|
|
33
|
+
Use instead of paragraphs saying "this folder does X, that folder does Y":
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<style>
|
|
37
|
+
.tree{font-family:var(--mono);background:var(--chip);border:1px solid var(--line);
|
|
38
|
+
border-radius:12px;padding:1em 1.2em;line-height:1.8;white-space:pre}
|
|
39
|
+
.tree .why{font-family:var(--sans);color:var(--muted);font-style:italic}
|
|
40
|
+
</style>
|
|
41
|
+
|
|
42
|
+
<div class="tree">src/
|
|
43
|
+
├─ api/ <span class="why">← every route the frontend can call</span>
|
|
44
|
+
├─ models/ <span class="why">← what the data looks like</span>
|
|
45
|
+
└─ jobs/ <span class="why">← work that runs on a schedule</span></div>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Rules: label every box/branch with WHY it matters, not just its name; keep a flow to 3–5
|
|
49
|
+
steps (split longer chains — shared pedagogy: one idea per screen); emoji are icons, pick
|
|
50
|
+
one each, don't decorate.
|
|
51
|
+
|
|
52
|
+
## In codebase-to-course
|
|
53
|
+
|
|
54
|
+
Courses get richer, *animated* versions from the prebuilt assets (flow animations, group-chat
|
|
55
|
+
visualizations, interactive architecture diagrams) — author against the contracts in the
|
|
56
|
+
skill's `interactive-elements.md`. These static idioms are the portable variants for decks
|
|
57
|
+
and briefings.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Visual-teaching principles
|
|
2
|
+
|
|
3
|
+
The shared pedagogy behind every praxis teaching surface — codebase-to-course's course screens,
|
|
4
|
+
educate's deck slides, research's briefing pages. Written once here; each skill keeps only its
|
|
5
|
+
medium-specific rules (and a one-line distillation as fallback) and points at this module.
|
|
6
|
+
|
|
7
|
+
**Graceful degradation:** if this module isn't available, the one-liner to keep: *one idea per
|
|
8
|
+
screen; show, don't tell; lead with the point; split, don't shrink.*
|
|
9
|
+
|
|
10
|
+
## The principles
|
|
11
|
+
|
|
12
|
+
1. **One idea per screen/slide.** Each unit of display teaches exactly one thing. If it feels
|
|
13
|
+
crammed, the fix is always MORE screens/slides, never smaller type or denser text. A
|
|
14
|
+
20-slide deck that's effortless beats a 6-slide deck you squint at.
|
|
15
|
+
|
|
16
|
+
2. **Show, don't tell.** Eyes glaze over text blocks. Prefer a diagram, card row, flow, code
|
|
17
|
+
translation, or animation to a paragraph — if you're writing a fourth sentence, stop and
|
|
18
|
+
convert it into a visual. Aim closer to an infographic than a textbook.
|
|
19
|
+
|
|
20
|
+
3. **Lead with the point.** State the claim/verdict/takeaway first, then justify it. A briefing
|
|
21
|
+
leads with the analysis's verdict; a deck leads with the lesson's claim; a course screen
|
|
22
|
+
leads with the concept, not the buildup. Don't make the reader reverse-engineer the answer.
|
|
23
|
+
|
|
24
|
+
4. **Split, don't shrink.** Never reduce type, tighten spacing, or trim whitespace to make
|
|
25
|
+
content fit. Cut words, or add another screen/slide. Readability is the budget everything
|
|
26
|
+
else pays from.
|
|
27
|
+
|
|
28
|
+
5. **Let visuals breathe.** A figure that matters gets the room it needs — its own slide, a
|
|
29
|
+
full-width screen block, generous whitespace around it. Alternate big visuals with narrow
|
|
30
|
+
text to create rhythm; don't wedge a diagram into a corner.
|
|
31
|
+
|
|
32
|
+
## How each medium applies them
|
|
33
|
+
|
|
34
|
+
- **Course screens** (codebase-to-course): every screen ≥50% visual, max 2–3 sentences per text
|
|
35
|
+
block, one "hero visual" per module — see the skill's `content-philosophy.md`.
|
|
36
|
+
- **Deck slides** (educate): large type is non-negotiable; verify nothing clips at 16:9 — see
|
|
37
|
+
the deck template's header rules.
|
|
38
|
+
- **Briefing pages** (research): verdict first, every figure traces to the analysis — see
|
|
39
|
+
`artifact-layer.md`.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Quiz patterns — test application, not memory
|
|
2
|
+
|
|
3
|
+
A quiz exists to check whether the learner can *use* what they learned on a new problem, not
|
|
4
|
+
whether they can regurgitate a definition. Originally codebase-to-course's quiz doctrine;
|
|
5
|
+
the design rules are medium-independent.
|
|
6
|
+
|
|
7
|
+
**Graceful degradation:** if this module isn't available, end the lesson/module with 2–3
|
|
8
|
+
"what would you do?" questions in plain prose and discuss the answers.
|
|
9
|
+
|
|
10
|
+
## The coverage rule (hard requirement)
|
|
11
|
+
|
|
12
|
+
A question may only use terms, concepts, components, and scenarios the material has already
|
|
13
|
+
introduced *before* the quiz. Applying knowledge to a brand-new situation is the whole point —
|
|
14
|
+
but every term the question leans on must be 100% guaranteed already taught. A learner should
|
|
15
|
+
never hit a quiz and think "we never talked about this." If a great question needs an
|
|
16
|
+
unintroduced concept, teach it first or cut the question.
|
|
17
|
+
|
|
18
|
+
## What to quiz (in order of value)
|
|
19
|
+
|
|
20
|
+
1. **"What would you do?" scenarios** — a new situation, apply what you learned. The gold standard.
|
|
21
|
+
2. **Debugging scenarios** — "X is broken; where would you look first?"
|
|
22
|
+
3. **Architecture decisions** — "would you put this logic here or there — and why?"
|
|
23
|
+
4. **Tracing exercises** — "when a user does X, what path does the data take?"
|
|
24
|
+
|
|
25
|
+
**Never quiz:** definitions (that's what glossing is for), file-name recall, syntax details,
|
|
26
|
+
or anything answerable by scrolling up and copying.
|
|
27
|
+
|
|
28
|
+
## Tone
|
|
29
|
+
|
|
30
|
+
Wrong answers get encouraging explanations that teach something new ("Not quite — here's
|
|
31
|
+
why…"); right answers get brief reinforcement of the principle ("Exactly — this works
|
|
32
|
+
because…"). Never punitive, never score-focused.
|
|
33
|
+
|
|
34
|
+
## Portable snippet (decks, briefings — zero JS)
|
|
35
|
+
|
|
36
|
+
`<details>`/`<summary>` gives tap-to-reveal answers with no script, keyboard-accessible,
|
|
37
|
+
token-styled:
|
|
38
|
+
|
|
39
|
+
```html
|
|
40
|
+
<style>
|
|
41
|
+
.quiz details{border:1px solid var(--line);border-radius:10px;margin:.4em 0;
|
|
42
|
+
background:var(--chip)}
|
|
43
|
+
.quiz summary{cursor:pointer;padding:.55em .9em;font-weight:600}
|
|
44
|
+
.quiz details p{margin:0;padding:.2em .9em .7em;color:var(--muted)}
|
|
45
|
+
.quiz details.right summary{border-left:4px solid var(--accent2)}
|
|
46
|
+
</style>
|
|
47
|
+
|
|
48
|
+
<div class="quiz">
|
|
49
|
+
<p><b>You want to add a "save to favorites" feature. Where does the logic live?</b></p>
|
|
50
|
+
<details><summary>A. In the browser extension</summary>
|
|
51
|
+
<p>Not quite — the extension only displays data; it never owns it. Look one hop back.</p></details>
|
|
52
|
+
<details class="right"><summary>B. In the backend API</summary>
|
|
53
|
+
<p>Exactly — the API owns the data, so a new feature that stores state starts there.</p></details>
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Deck usage: one question per slide, reveal by tapping — a natural end-of-lesson slide before
|
|
58
|
+
the takeaways.
|
|
59
|
+
|
|
60
|
+
## In codebase-to-course
|
|
61
|
+
|
|
62
|
+
Courses do NOT use this snippet — the prebuilt assets ship graded quiz engines
|
|
63
|
+
(multiple-choice, drag-and-drop, spot-the-bug, scenario) with per-answer explanations; author
|
|
64
|
+
against the contracts in the skill's `interactive-elements.md`. The coverage rule above
|
|
65
|
+
applies everywhere.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Inline-SVG diagram rules
|
|
2
|
+
|
|
3
|
+
Hand-drawn inline SVG is the praxis idiom for explanatory figures (educate decks draw
|
|
4
|
+
diagrams this way; research briefings draw charts as inline SVG). SVG has sharp edges that
|
|
5
|
+
HTML habits walk straight into — these are the hard-won rules, originally from educate's
|
|
6
|
+
deck template.
|
|
7
|
+
|
|
8
|
+
**Graceful degradation:** if this module isn't available, the two rules to keep: *no
|
|
9
|
+
`<b>`/`<i>` inside SVG `<text>` (use `<tspan>`), and CSS `var(--…)` does not resolve in SVG
|
|
10
|
+
presentation attributes — use literal values.*
|
|
11
|
+
|
|
12
|
+
## The pitfalls (hard rules)
|
|
13
|
+
|
|
14
|
+
1. **Never put `<b>` or `<i>` inside an SVG `<text>` element.** HTML formatting tags are
|
|
15
|
+
invisible-but-broken inside SVG — the text silently fails to render or renders unstyled.
|
|
16
|
+
Use `<tspan font-weight="700">` / `<tspan font-style="italic">` instead.
|
|
17
|
+
|
|
18
|
+
2. **CSS `var(--token)` does NOT resolve in SVG presentation attributes** (`fill="var(--accent)"`
|
|
19
|
+
silently paints black). Use **literal color values** in SVG attributes — and since literals
|
|
20
|
+
can't follow the theme, pick colors that read on BOTH light and dark backgrounds, or give
|
|
21
|
+
the figure its own contrasting panel. (`var()` does work in a `style=""` attribute or CSS
|
|
22
|
+
rule targeting SVG elements — but only for properties CSS controls, and it's easy to get
|
|
23
|
+
this half-right; literals are the predictable path.)
|
|
24
|
+
|
|
25
|
+
3. **Size for reflow:** `width:100%; height:auto` on the `<svg>` plus a `viewBox` — never fixed
|
|
26
|
+
pixel width/height attributes alone, or the figure won't scale with its container.
|
|
27
|
+
|
|
28
|
+
4. **Accessibility:** `role="img"` and an `aria-label` describing what the diagram shows.
|
|
29
|
+
|
|
30
|
+
5. **Give the figure room** (shared pedagogy — "let visuals breathe"): a diagram that carries
|
|
31
|
+
the point gets its own slide / a full-width block, filling most of the available width and
|
|
32
|
+
height. Don't wedge it beside a paragraph.
|
|
33
|
+
|
|
34
|
+
## Skeleton — a boxes-and-arrows flow
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<svg viewBox="0 0 800 300" role="img" aria-label="input flows through process to output">
|
|
38
|
+
<rect x="40" y="110" width="200" height="80" rx="12" fill="#eef0ff" stroke="#4f46e5" stroke-width="2"/>
|
|
39
|
+
<text x="140" y="158" text-anchor="middle" font-size="26" fill="#1b2733" font-family="sans-serif">Input</text>
|
|
40
|
+
<line x1="240" y1="150" x2="320" y2="150" stroke="#5a6776" stroke-width="3" marker-end="url(#a)"/>
|
|
41
|
+
<rect x="320" y="110" width="200" height="80" rx="12" fill="#eefaf7" stroke="#0e9488" stroke-width="2"/>
|
|
42
|
+
<text x="420" y="158" text-anchor="middle" font-size="26" fill="#1b2733" font-family="sans-serif">Process</text>
|
|
43
|
+
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
|
|
44
|
+
<path d="M0,0 L8,3 L0,6 Z" fill="#5a6776"/></marker></defs>
|
|
45
|
+
</svg>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Notes: pastel fills + saturated strokes read on both themes; the `<marker>` def gives every
|
|
49
|
+
arrow a clean head — define it once per SVG and reference with `marker-end="url(#a)"`; keep
|
|
50
|
+
marker `id`s unique per page if a page holds several SVGs.
|
|
51
|
+
|
|
52
|
+
## Who uses this
|
|
53
|
+
|
|
54
|
+
- **educate decks** — the native idiom; the deck template's header carries the one-line
|
|
55
|
+
distillation and a sample diagram slide.
|
|
56
|
+
- **research briefings** — charts are inline SVG; these rules apply on top of the `dataviz`
|
|
57
|
+
skill's chart guidance.
|
|
58
|
+
- **codebase-to-course** — courses prefer the HTML/CSS + emoji diagram patterns in the skill's
|
|
59
|
+
`interactive-elements.md`; when a course page does hand-draw inline SVG, these rules apply.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Jargon glossary tooltip
|
|
2
|
+
|
|
3
|
+
Gloss every technical term where it appears, so the reader never has to leave the page (or
|
|
4
|
+
ask) to decode jargon. The definition rides a `data-tip` attribute; a popover shows it on
|
|
5
|
+
hover (desktop) or tap (touch). This is the single most valuable accessibility feature for
|
|
6
|
+
non-technical audiences — codebase-to-course's content rules call it "No Term Left Behind."
|
|
7
|
+
|
|
8
|
+
**Graceful degradation:** if this module isn't available, gloss terms in parentheses on
|
|
9
|
+
first use — `the manifest (the file that declares what the extension may do)` — and move on.
|
|
10
|
+
|
|
11
|
+
## The contract
|
|
12
|
+
|
|
13
|
+
Any element — HTML *or* SVG — may carry a `data-tip` attribute with a 1–2 sentence,
|
|
14
|
+
everyday-language definition:
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<span data-tip="A service worker is a background script that runs independently of the
|
|
18
|
+
page — a behind-the-scenes assistant that's on even when you're not looking.">service
|
|
19
|
+
worker</span>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Give the reader a **visible affordance** so glossed terms are discoverable: a dashed
|
|
23
|
+
underline, or a small `?` badge (educate's decks use the badge — see the deck template's
|
|
24
|
+
sample slide).
|
|
25
|
+
- Keep definitions to 1–2 sentences, everyday language; a metaphor helps.
|
|
26
|
+
- Gloss on **first use per screen/slide**, not every repetition.
|
|
27
|
+
|
|
28
|
+
## The snippet
|
|
29
|
+
|
|
30
|
+
Self-contained, and written against the **shared token names** (`--ink`, `--bg`) so it
|
|
31
|
+
inherits the consuming plugin's palette in both light and dark themes (the bubble is
|
|
32
|
+
inverted-contrast: dark-on-light pages get a dark bubble and vice versa). Popover text size
|
|
33
|
+
follows `--tip-size` (default `1rem`) — decks set it larger. The popover is appended to
|
|
34
|
+
`document.body` with `position: fixed`, so it is **never clipped** by an ancestor's
|
|
35
|
+
`overflow: hidden`, and it hides when printing.
|
|
36
|
+
|
|
37
|
+
Consumers embed the marked regions verbatim; `scripts/sync-shared.mjs` re-stamps them (drift
|
|
38
|
+
fails the test suite). Hand-edit only here.
|
|
39
|
+
|
|
40
|
+
```css
|
|
41
|
+
/* praxis:tooltip-css:start */
|
|
42
|
+
[data-tip]{cursor:help}
|
|
43
|
+
.tip-pop{position:fixed;z-index:1000;max-width:min(420px,80vw);background:var(--ink);color:var(--bg);
|
|
44
|
+
font-size:var(--tip-size,1rem);line-height:1.4;padding:.65em .9em;border-radius:10px;
|
|
45
|
+
pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;transform:translateY(4px);
|
|
46
|
+
transition:opacity .12s ease,transform .12s ease}
|
|
47
|
+
.tip-pop.show{opacity:1;transform:translateY(0)}
|
|
48
|
+
@media print{ .tip-pop{display:none} }
|
|
49
|
+
/* praxis:tooltip-css:end */
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// praxis:tooltip-js:start
|
|
54
|
+
// One shared popover: follows the cursor on hover, toggles on click/tap (touch), clamps to
|
|
55
|
+
// the viewport, and closes on tap-away. Works for HTML and SVG [data-tip] elements alike.
|
|
56
|
+
(function(){
|
|
57
|
+
const pop=document.createElement('div'); pop.className='tip-pop'; document.body.appendChild(pop);
|
|
58
|
+
let shownFor=null;
|
|
59
|
+
const place=(x,y)=>{ const pad=14, r=pop.getBoundingClientRect();
|
|
60
|
+
let px=x+pad, py=y+pad;
|
|
61
|
+
if(px+r.width>innerWidth-8) px=Math.max(8,x-r.width-pad);
|
|
62
|
+
if(py+r.height>innerHeight-8) py=Math.max(8,y-r.height-pad);
|
|
63
|
+
pop.style.left=px+'px'; pop.style.top=py+'px'; };
|
|
64
|
+
const show=(t,x,y)=>{ pop.textContent=t.getAttribute('data-tip'); pop.classList.add('show'); shownFor=t; place(x,y); };
|
|
65
|
+
const hide=()=>{ pop.classList.remove('show'); shownFor=null; };
|
|
66
|
+
document.addEventListener('mouseover',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t) show(t,e.clientX,e.clientY); });
|
|
67
|
+
document.addEventListener('mousemove',e=>{ if(shownFor) place(e.clientX,e.clientY); });
|
|
68
|
+
document.addEventListener('mouseout',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t && !t.contains(e.relatedTarget)) hide(); });
|
|
69
|
+
document.addEventListener('click',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]');
|
|
70
|
+
if(t){ e.stopPropagation(); (shownFor===t)?hide():show(t,e.clientX,e.clientY); } else hide(); });
|
|
71
|
+
})();
|
|
72
|
+
// praxis:tooltip-js:end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Where each plugin gets it
|
|
76
|
+
|
|
77
|
+
- **educate decks** — the deck template carries the stamped regions; nothing to author.
|
|
78
|
+
- **research briefings** — copy both regions into the page (alongside the base.html blocks).
|
|
79
|
+
- **codebase-to-course pages** — courses do NOT use this snippet: the prebuilt assets
|
|
80
|
+
(`references/styles.css` + `references/main.js`, copied verbatim into every course) ship
|
|
81
|
+
the same pattern as a course-native engine with the `.term` + `data-definition` contract
|
|
82
|
+
and a dashed-underline affordance. Author course HTML against that contract and never
|
|
83
|
+
inline tooltip CSS/JS there (see the skill's `interactive-elements.md`).
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@praxisflux/gates",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "praxis gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/evanstern/praxis.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"praxis-gates": "scripts/run-gates.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-gates.mjs — the praxis CI consumption surface: run selected praxis gates against a
|
|
3
|
+
// consumer repository. This is what action.yml (the composite GitHub Action) invokes; it also
|
|
4
|
+
// runs by hand from any praxis checkout:
|
|
5
|
+
//
|
|
6
|
+
// node scripts/run-gates.mjs --gates spec-bridge,wiki-freshness [--path <root>]
|
|
7
|
+
// [--wiki-dir docs/wiki] [--course-dir docs/course]
|
|
8
|
+
//
|
|
9
|
+
// Gate names, options, and exit codes are praxis's versioned consumer contract
|
|
10
|
+
// (docs/consuming-gates.md): exit 0 when every gate passes, 1 when any gate fails, 2 on a
|
|
11
|
+
// usage error (unknown gate, missing --gates). Each failure line names its fix. This same
|
|
12
|
+
// file ships as the @praxisflux/gates npm bin (scripts/build-npm.mjs carves the package).
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { runAsCli } from "../lib/cli.mjs";
|
|
16
|
+
import { checkBridge } from "../spec-bridge/gates/bridge.mjs";
|
|
17
|
+
import { validateFreshness } from "../grounding-wiki/gates/freshness.mjs";
|
|
18
|
+
import { validateCourse } from "../codebase-to-course/gates/course.mjs";
|
|
19
|
+
|
|
20
|
+
/** wiki-freshness resolves verified_against pins through git history, which a shallow CI
|
|
21
|
+
* clone doesn't have — catch that up front with the exact fix instead of a wall of
|
|
22
|
+
* unknown-pin failures. */
|
|
23
|
+
function shallowCloneProblem(root) {
|
|
24
|
+
const r = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: root, encoding: "utf8" });
|
|
25
|
+
if (r.status === 0 && r.stdout.trim() === "true")
|
|
26
|
+
return `${root} is a shallow clone — wiki-freshness needs full history to resolve verified_against pins; set "fetch-depth: 0" on actions/checkout`;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Each gate takes {root, wikiDir, courseDir} and returns {problems, warnings, ok} where
|
|
31
|
+
* ok is the one-line success summary. */
|
|
32
|
+
export const GATES = {
|
|
33
|
+
"spec-bridge": ({ root }) => {
|
|
34
|
+
const { links, problems, warnings } = checkBridge(root);
|
|
35
|
+
return { problems, warnings, ok: `spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts` };
|
|
36
|
+
},
|
|
37
|
+
"wiki-freshness": ({ root, wikiDir }) => {
|
|
38
|
+
const shallow = shallowCloneProblem(root);
|
|
39
|
+
if (shallow) return { problems: [shallow], warnings: [], ok: "" };
|
|
40
|
+
const { fails, warns, checked } = validateFreshness(root, wikiDir);
|
|
41
|
+
return { problems: fails, warnings: warns, ok: `wiki-freshness ok: ${checked} note(s) fresh against their pinned sources` };
|
|
42
|
+
},
|
|
43
|
+
"course": ({ root, courseDir }) => {
|
|
44
|
+
const { fails, warns, modules } = validateCourse(join(root, courseDir));
|
|
45
|
+
return { problems: fails, warnings: warns, ok: `course ok: ${modules} module(s) pass the course gate` };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Run the named gates against opts.root. Returns [{gate, problems, warnings, ok}].
|
|
50
|
+
* Throws on an unknown or empty gate list — a misspelled gate must fail the build loudly,
|
|
51
|
+
* never skip silently. */
|
|
52
|
+
export function runGates(names, opts) {
|
|
53
|
+
if (!names.length) throw new Error(`no gates requested — pass --gates with any of: ${Object.keys(GATES).join(", ")}`);
|
|
54
|
+
for (const n of names)
|
|
55
|
+
if (!GATES[n]) throw new Error(`unknown gate "${n}" — valid gates: ${Object.keys(GATES).join(", ")}`);
|
|
56
|
+
return names.map((gate) => ({ gate, ...GATES[gate](opts) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (runAsCli(import.meta.url)) {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
const opt = (name, dflt) => {
|
|
62
|
+
const i = args.indexOf(`--${name}`);
|
|
63
|
+
return i !== -1 && args[i + 1] !== undefined ? args[i + 1] : dflt;
|
|
64
|
+
};
|
|
65
|
+
const opts = {
|
|
66
|
+
root: resolve(opt("path", ".")),
|
|
67
|
+
wikiDir: opt("wiki-dir", "docs/wiki"),
|
|
68
|
+
courseDir: opt("course-dir", "docs/course"),
|
|
69
|
+
};
|
|
70
|
+
const names = opt("gates", "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
71
|
+
|
|
72
|
+
let results;
|
|
73
|
+
try {
|
|
74
|
+
results = runGates(names, opts);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error(`usage error: ${e.message}`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
let failed = 0;
|
|
80
|
+
for (const { gate, problems, warnings, ok } of results) {
|
|
81
|
+
for (const w of warnings) console.log(`[${gate}] warn: ${w}`);
|
|
82
|
+
if (problems.length) {
|
|
83
|
+
failed++;
|
|
84
|
+
console.log(`[${gate}] GATE FAILED (${problems.length} issue(s)):`);
|
|
85
|
+
for (const p of problems) console.log(` - ${p}`);
|
|
86
|
+
} else {
|
|
87
|
+
console.log(`[${gate}] ${ok}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
process.exit(failed ? 1 : 0);
|
|
91
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// bridge.mjs — read-only logic of the spec-bridge: find Backlog tasks linked to Spec Kit spec
|
|
2
|
+
// dirs and judge each task's kanban status against the state its spec artifacts prove.
|
|
3
|
+
//
|
|
4
|
+
// A task is "linked" by a marker line in its description: `Spec: <dir>` where <dir> is the spec
|
|
5
|
+
// directory relative to the project root (the dir holding backlog/). The marker is planted and
|
|
6
|
+
// maintained ONLY via the backlog CLI (link/sync skills); this module just reads the task files.
|
|
7
|
+
//
|
|
8
|
+
// Verdicts per linked task, comparing the task's frontmatter status to the derived status:
|
|
9
|
+
// exceeds — status claims more than the artifacts prove (e.g. Done over unchecked boxes).
|
|
10
|
+
// The Stop-hook gate blocks on these.
|
|
11
|
+
// lags — artifacts are ahead of the status (honest but stale). Warn, never block.
|
|
12
|
+
// ok — they agree.
|
|
13
|
+
// unknown — the task uses a status outside To Do / In Progress / Done (custom workflow);
|
|
14
|
+
// the bridge doesn't guess, so it neither blocks nor warns.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { deriveSpecState, STATUS } from "../lib/spec-derive.mjs";
|
|
19
|
+
import { hasChild, findRootsDownwards } from "../lib/project-root.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-project bridge config: `.spec-bridge.json` at the project root (beside backlog/).
|
|
23
|
+
* `{ "strictDone": true }` turns on analyze-gated Done (see lib/spec-derive.mjs). Missing or
|
|
24
|
+
* malformed config means checkbox-only mode — strictness is opt-in.
|
|
25
|
+
*/
|
|
26
|
+
export function loadBridgeConfig(root) {
|
|
27
|
+
try { return JSON.parse(readFileSync(join(root, ".spec-bridge.json"), "utf8")) ?? {}; }
|
|
28
|
+
catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
|
|
32
|
+
const RANK = { "to do": 0, "in progress": 1, done: 2 };
|
|
33
|
+
const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse one Backlog task file. Returns { id, status, specDir } for a linked task,
|
|
37
|
+
* null for anything else (no marker, unreadable, or not a task file).
|
|
38
|
+
*/
|
|
39
|
+
export function parseLinkedTask(raw) {
|
|
40
|
+
const text = String(raw ?? "");
|
|
41
|
+
const marker = text.match(MARKER);
|
|
42
|
+
if (!marker) return null;
|
|
43
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
44
|
+
if (!fm) return null;
|
|
45
|
+
const field = (name) => fm[1].match(new RegExp(`^${name}:\\s*(.+?)\\s*$`, "m"))?.[1]?.replace(/^['"]|['"]$/g, "") ?? "";
|
|
46
|
+
const id = field("id");
|
|
47
|
+
const status = field("status");
|
|
48
|
+
if (!id) return null;
|
|
49
|
+
return { id, status, specDir: marker[1] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
53
|
+
export function findLinkedTasks(root) {
|
|
54
|
+
const dir = join(root, "backlog", "tasks");
|
|
55
|
+
let entries = [];
|
|
56
|
+
try { entries = readdirSync(dir); } catch { return []; }
|
|
57
|
+
const linked = [];
|
|
58
|
+
for (const name of entries.filter((n) => n.endsWith(".md")).sort()) {
|
|
59
|
+
try {
|
|
60
|
+
const task = parseLinkedTask(readFileSync(join(dir, name), "utf8"));
|
|
61
|
+
if (task) linked.push({ ...task, file: join(dir, name) });
|
|
62
|
+
} catch { /* skip unreadable */ }
|
|
63
|
+
}
|
|
64
|
+
return linked;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Compare a task's Backlog status to its derived status: "exceeds" | "lags" | "ok" | "unknown". */
|
|
68
|
+
export function verdict(taskStatus, derivedStatus) {
|
|
69
|
+
const t = RANK[String(taskStatus).toLowerCase()];
|
|
70
|
+
const d = DERIVED_RANK[derivedStatus];
|
|
71
|
+
if (t === undefined || d === undefined) return "unknown";
|
|
72
|
+
return t > d ? "exceeds" : t < d ? "lags" : "ok";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One human sentence on why a spec dir doesn't prove more than its derived status. */
|
|
76
|
+
function shortfall(root, specDir, derived) {
|
|
77
|
+
const missing = ["spec.md", "plan.md"].filter((f) => !existsSync(join(root, specDir, f)));
|
|
78
|
+
const parts = missing.map((f) => `${f} missing`);
|
|
79
|
+
if (derived.tasksTotal === 0) parts.push("no tasks in tasks.md");
|
|
80
|
+
else if (derived.tasksDone < derived.tasksTotal)
|
|
81
|
+
parts.push(`${derived.tasksTotal - derived.tasksDone} of ${derived.tasksTotal} tasks unchecked (${derived.progressNote})`);
|
|
82
|
+
if (derived.analysis?.required) {
|
|
83
|
+
if (!derived.analysis.present)
|
|
84
|
+
parts.push("analysis.md missing (strict Done: save the /speckit.analyze report into the spec dir)");
|
|
85
|
+
else if (derived.analysis.criticals.length)
|
|
86
|
+
parts.push(`unresolved CRITICAL finding(s) in analysis.md: ${derived.analysis.criticals.join(" | ")}`);
|
|
87
|
+
}
|
|
88
|
+
return parts.join(", ") || "artifacts incomplete";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Judge every linked task under <root>. Returns:
|
|
93
|
+
* links — [{ id, status, specDir, derived, verdict }]
|
|
94
|
+
* problems — blocking messages, one per "exceeds"
|
|
95
|
+
* warnings — non-blocking messages, one per "lags"
|
|
96
|
+
*/
|
|
97
|
+
export function checkBridge(root) {
|
|
98
|
+
const links = [];
|
|
99
|
+
const problems = [];
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const requireAnalysis = loadBridgeConfig(root).strictDone === true;
|
|
102
|
+
for (const task of findLinkedTasks(root)) {
|
|
103
|
+
const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
|
|
104
|
+
const v = verdict(task.status, derived.status);
|
|
105
|
+
links.push({ ...task, derived, verdict: v });
|
|
106
|
+
if (v === "exceeds") {
|
|
107
|
+
problems.push(
|
|
108
|
+
`[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} only proves "${derived.status}": ` +
|
|
109
|
+
`${shortfall(root, task.specDir, derived)}. Finish the spec work or set the task back (backlog task edit ${task.id} -s "...").`
|
|
110
|
+
);
|
|
111
|
+
} else if (v === "lags") {
|
|
112
|
+
warnings.push(
|
|
113
|
+
`[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} already derives "${derived.status}" — run the spec-bridge sync skill to catch the board up.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { links, problems, warnings };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
|
|
122
|
+
* a root with no linked tasks yields no problems, so the gate is a natural no-op outside
|
|
123
|
+
* bridged projects. "exceeds" blocks; "lags" only warns.
|
|
124
|
+
*/
|
|
125
|
+
export const bridgeGate = {
|
|
126
|
+
name: "spec-bridge",
|
|
127
|
+
resolveRoots: (startDir) => findRootsDownwards(startDir, hasChild("backlog")),
|
|
128
|
+
check: (root) => checkBridge(root).problems,
|
|
129
|
+
warn: (root) => checkBridge(root).warnings,
|
|
130
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.mjs — command-line entry to the spec-bridge gates (read-only; all Backlog writes go
|
|
3
|
+
// through the backlog CLI in the skills). The skills call these instead of parsing markdown:
|
|
4
|
+
// node cli.mjs state <specDir> derived state for one spec dir, as JSON
|
|
5
|
+
// node cli.mjs links <root> every linked task under <root> with derived state + verdict, as JSON
|
|
6
|
+
// node cli.mjs check <root> human report; exit 1 if any task's status exceeds its artifacts
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { deriveSpecState } from "../lib/spec-derive.mjs";
|
|
9
|
+
import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
|
|
10
|
+
import { checkBridge, loadBridgeConfig } from "./bridge.mjs";
|
|
11
|
+
|
|
12
|
+
const [cmd, target] = process.argv.slice(2);
|
|
13
|
+
if (!cmd || !target) {
|
|
14
|
+
console.error("usage: cli.mjs state <specDir> | links <root> | check <root>");
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (cmd === "state") {
|
|
19
|
+
// Honor the project's .spec-bridge.json (strictDone) — same config checkBridge uses.
|
|
20
|
+
const root = findRootUpwards(resolve(target), hasChild("backlog"));
|
|
21
|
+
const requireAnalysis = root ? loadBridgeConfig(root).strictDone === true : false;
|
|
22
|
+
console.log(JSON.stringify(deriveSpecState(target, { requireAnalysis }), null, 2));
|
|
23
|
+
} else if (cmd === "links") {
|
|
24
|
+
console.log(JSON.stringify(checkBridge(target).links, null, 2));
|
|
25
|
+
} else if (cmd === "check") {
|
|
26
|
+
const { links, problems, warnings } = checkBridge(target);
|
|
27
|
+
for (const w of warnings) console.log(`warn: ${w}`);
|
|
28
|
+
if (problems.length) {
|
|
29
|
+
console.log(`\nGATE FAILED (${problems.length} issue(s)):`);
|
|
30
|
+
for (const p of problems) console.log(` - ${p}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`unknown command: ${cmd}`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# lib — the shared Node chassis
|
|
2
|
+
|
|
3
|
+
Zero-dependency Node modules shared by every plugin, **vendored into each plugin at build time**
|
|
4
|
+
(a shipped `.plugin` is self-contained; `${CLAUDE_PLUGIN_ROOT}/lib/…` resolves at runtime).
|
|
5
|
+
|
|
6
|
+
Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harness) · `markdown`
|
|
7
|
+
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
|
+
`installer` · `dates` · `template`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// cli.mjs — the run-as-CLI guard for dual-use modules (importable library + executable
|
|
2
|
+
// script).
|
|
3
|
+
//
|
|
4
|
+
// The naive `import.meta.url === `file://${process.argv[1]}`` comparison breaks through
|
|
5
|
+
// symlinks: Node resolves import.meta.url to the module's physical path, while argv[1] stays
|
|
6
|
+
// as typed, so a script invoked via a symlinked checkout (~/projects -> Claude/Code) compares
|
|
7
|
+
// unequal and silently runs none of its CLI body — for a gate runner that means exit 0 having
|
|
8
|
+
// checked nothing, the exact silent skip the gates doctrine forbids. Realpath both sides so
|
|
9
|
+
// the comparison is invocation-path-independent.
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
/** True when the module at `moduleUrl` (pass import.meta.url) is the script Node was asked
|
|
14
|
+
* to run, regardless of symlinks on either side. False when imported as a module, or when
|
|
15
|
+
* there is no entry script (`node -e`, REPL) or argv[1] is a runner's virtual entry. */
|
|
16
|
+
export function runAsCli(moduleUrl) {
|
|
17
|
+
if (!process.argv[1]) return false;
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(process.argv[1]);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// dates.mjs — ISO date helpers shared across the suite.
|
|
2
|
+
|
|
3
|
+
/** Today's date (or `date`'s) as an ISO `YYYY-MM-DD` string. */
|
|
4
|
+
export function today(date = new Date()) {
|
|
5
|
+
return date.toISOString().slice(0, 10);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Rewrite an `updated:` frontmatter line to today's date. No-op if none present. */
|
|
9
|
+
export function bumpUpdated(text, date = new Date()) {
|
|
10
|
+
return (text || "").replace(/^(updated:\s*).*$/m, `$1${today(date)}`);
|
|
11
|
+
}
|