Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c1b37c0b | ||
|
|
b004fcd644 | ||
|
|
0c7fe15635 | ||
|
|
2be3b32586 | ||
|
|
867132f26b | ||
|
|
4522b8fa40 | ||
|
|
2d221f6204 | ||
|
|
626dc1df8f | ||
|
|
02396f4482 |
+23
-9
@@ -110,8 +110,10 @@ whole browsing session and sends activity messages over it — JSON text
|
||||
frames matching the server's `Ping` msgspec struct with the fields `fr`
|
||||
(source path), `to` (navigation target), `read` (active seconds on `fr`
|
||||
since the last report), `lang` (the rendered language of the page the
|
||||
activity happened on — its `<html lang>`) and `hide`; falsy fields are
|
||||
omitted. One channel
|
||||
activity happened on — its `<html lang>`, except the language-switch
|
||||
navigation ping, which passes the picked tag explicitly because the view
|
||||
transition applies the new `<html lang>` only after the ping goes out) and
|
||||
`hide`; falsy fields are omitted. One channel
|
||||
follows the session, so the activity of a visit stays tied together, and
|
||||
while the user is active the accumulated reading time is flushed every few
|
||||
seconds: the times are incremental, so a disconnection simply leaves the
|
||||
@@ -362,13 +364,25 @@ Axes always start at 0 and end at a multiple of a 1-2-5 major step (max 5
|
||||
labeled intervals, minor lines at fifths when integral; the minimum y-axis
|
||||
range is 10 so tiny values such as a single visit are not stretched to a
|
||||
fractional scale).
|
||||
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
||||
weeks in the muted color at decreasing opacity (the current week keeps the
|
||||
accent color and is
|
||||
truncated at the current bucket, never drawing fake zeroes for the future);
|
||||
a compact legend inside the top right of the visits chart marks the current
|
||||
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
|
||||
a muted specimen. Its x labels are weekday names centered at midday UTC, without
|
||||
The week range is aligned to Monday 00:00 UTC (the current week keeps the
|
||||
accent color and is truncated at the current bucket, never drawing fake
|
||||
zeroes for the future). Both the week and day views overlay a **"typical"
|
||||
history curve** in the muted color (`analytics/seasonal.js`, a port of
|
||||
`seasonal.py`): the whole recorded history is densified to 5-minute bins,
|
||||
smoothed with the same Gaussian as the week view, then folded onto a weekly
|
||||
grid with exponential decay over age — a 7-day half-life for the average
|
||||
time-of-day pattern and a 42-day half-life for per-weekday deviations from
|
||||
it, the deviation shrunk by the effective number of weeks behind each bin
|
||||
(`n_eff / (n_eff + 3)`) so the estimate falls back to the common daily
|
||||
pattern when history is short. History is capped at the most recent 180
|
||||
days, beyond which even the slow kernel's weight is negligible (~5%). The
|
||||
week view draws the full Monday-first
|
||||
estimate as "Typical week" (future included); the day view cuts the rolling
|
||||
24-hour window's bins from the same estimate and labels them by the weekday
|
||||
("Typical Saturday"). A compact legend inside the top right of the visits
|
||||
chart marks the current data in accent (ISO week label, or a bar specimen
|
||||
for "Last 24 hours") and the typical curve on a muted specimen. The week
|
||||
view's x labels are weekday names centered at midday UTC, without
|
||||
vertical grid
|
||||
lines (day boundaries would be misleading in the viewer's timezone). The
|
||||
month view labels days the same lineless way — day numbers at noon UTC,
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
|
||||
|
||||
## `markdown.py`
|
||||
|
||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
|
||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A lone `{name}` / `{name: args}` line is a block directive: a core rule turns it into a `directive` token (render instance only — the verbatim parser keeps the plain paragraph so segments/chunks see the placeholder source), and the render rule delegates to the resolvers passed as `render(directives=...)`, leaving the source literal where no resolver applies (e.g. the editor preview of a page that does not exist yet). Built in: `{dates}` expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`, registered by `render()` when `created` is given); views.py resolves `{cards}` — the page's published children, one card each (on the front page: the other top-level pages) — and `{cards: path path/* path/** ...}` (space-separated: a plain path renders that page alone, `path/*` its published children, `path/**` all published descendant pages; a page-less item is represented by its first leaf page, the nav-link logic) into the same card-row markup as category pages (`.cards.wide`, a boundary block outside the column segments). A page with any `{cards}` tag drops the automatic end-of-page child cards; multiple tags each render their own row. Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
|
||||
|
||||
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (margin-breakout boxes — `.margin`, `::: aside` — stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
|
||||
|
||||
|
||||
@@ -33,14 +33,9 @@ Deliberately simple — **q-values are ignored**:
|
||||
- Selection rule (`select_language` in `pagerite/i18n.py`):
|
||||
1. If `?lang=<tag>` is present, use it (if a translation exists; otherwise
|
||||
fall through to header logic).
|
||||
2. If the article's original language appears anywhere in the header list,
|
||||
use the **original**. Rationale: an AI translation is strictly worse
|
||||
than the original for anyone who has that language configured at all
|
||||
(e.g. `fi-FI, fi, en-US, en` gets English, not machine-translated
|
||||
Finnish).
|
||||
3. Otherwise walk the header list in order and use the first language for
|
||||
which a translation exists.
|
||||
4. Fall back to the original.
|
||||
2. Otherwise walk the header list in order and use the first language that
|
||||
can be served — the original, or one with an available translation.
|
||||
3. Fall back to the original.
|
||||
|
||||
Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ onUnmounted(() => {
|
||||
const window = computed(() => rangeWindow(range.value))
|
||||
|
||||
// All non-chart stats follow the selected range; the charts keep their own
|
||||
// range-specific x windows (week overlays previous weeks aligned to Monday).
|
||||
// range-specific x windows (week aligned to Monday, overlaid with the
|
||||
// seasonal "typical week" curve).
|
||||
const rangeData = computed(() => {
|
||||
if (!data.value) return null
|
||||
const { t0, t1 } = window.value
|
||||
@@ -481,10 +482,19 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
}
|
||||
|
||||
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
|
||||
summary too); keep the inline-flex layout from the component. */
|
||||
.visit-table .trail a.referer-badge {
|
||||
summary too); keep the inline-flex layout from the component. The
|
||||
generic trail-link rule above would otherwise clip the badge (overflow:
|
||||
hidden, hiding the absolutely positioned favicon) and cap its inner
|
||||
link — the link is display: contents, so its parts lay out as badge
|
||||
flex items. */
|
||||
.visit-table .trail .referer-badge {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.visit-table .trail .referer-badge a.badge-link {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
|
||||
|
||||
@@ -736,17 +736,15 @@ function previewIntoArticle(html, multicol) {
|
||||
if (!article) return
|
||||
// The server render owns the article completely — the injected title h1,
|
||||
// the column layout (.multicol on the article, the .colseg/.cols
|
||||
// segments) — so the whole article content swaps as one. Only the edit
|
||||
// pen and the category cards survive: detach them before innerHTML wipes
|
||||
// them. pagerite.js re-places the pen into the first visible h1 on
|
||||
// pagerite:preview.
|
||||
// segments), the card stacks ({cards} tags expanded, or the children's
|
||||
// cards appended when the page has no tag) — so the whole article
|
||||
// content swaps as one. Only the edit pen survives: detach it before
|
||||
// innerHTML wipes it. pagerite.js re-places the pen into the first
|
||||
// visible h1 on pagerite:preview.
|
||||
article.classList.toggle('multicol', multicol)
|
||||
const pen = article.querySelector('button.edit-link')
|
||||
if (pen) pen.remove()
|
||||
const cards = article.querySelector(':scope > .cards')
|
||||
if (cards) cards.remove()
|
||||
article.innerHTML = html
|
||||
if (cards) article.append(cards)
|
||||
runScripts(article)
|
||||
dispatchEvent(new CustomEvent('pagerite:preview'))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
// Referer + UTM as one badge in the analytics visit/crawler tables: the
|
||||
// referer's favicon flush on the left, its host as the link text, then the
|
||||
// UTM summary smaller/muted inside the same badge. The whole badge links to
|
||||
// the referer origin (external, new tab) and carries a single one-fact-
|
||||
// per-line tooltip (badge.title: origin, then each utm pair) — no titles on
|
||||
// the inner elements. Referers are external, so there is no close event.
|
||||
// UTM summary smaller/muted inside the same badge. Only the favicon and
|
||||
// host are the link (external, new tab) — everything else, padding and
|
||||
// UTM text included, copies the full utm tag list to the clipboard. The
|
||||
// link is display: contents so its parts lay out as badge flex items.
|
||||
// The badge carries a single one-fact-per-line tooltip (badge.title:
|
||||
// origin, then each utm pair) — no titles on the inner elements.
|
||||
// Referers are external, so there is no close event.
|
||||
import { computed } from 'vue'
|
||||
import { copyList } from './analytics/format.js'
|
||||
|
||||
const props = defineProps({
|
||||
badge: { type: Object, required: true },
|
||||
@@ -16,53 +20,94 @@ const favicon = computed(() => (props.badge.origin ? props.favicons?.[props.badg
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="referer-badge"
|
||||
:href="badge.href || undefined"
|
||||
:title="badge.title"
|
||||
:target="badge.href ? '_blank' : undefined"
|
||||
:rel="badge.href ? 'noopener' : undefined">
|
||||
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||
<span v-if="badge.label">{{ badge.label }}</span>
|
||||
<span class="referer-badge"
|
||||
:class="{ 'with-icon': favicon, copyable: badge.utm }"
|
||||
:title="badge.title"
|
||||
@click="badge.utm && copyList(badge.utmCopy, $event)">
|
||||
<a v-if="badge.href" class="badge-link" :href="badge.href"
|
||||
target="_blank" rel="noopener" @click.stop>
|
||||
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||
<span v-if="badge.label">{{ badge.label }}</span>
|
||||
</a>
|
||||
<template v-else>
|
||||
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||
<span v-if="badge.label">{{ badge.label }}</span>
|
||||
</template>
|
||||
<small v-if="badge.utm" class="small">{{ badge.utm }}</small>
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Browser-chrome chip on a fixed neutral palette (--badge-* in
|
||||
/* Browser-chrome chip on a translucent neutral wash (--badge-* in
|
||||
pagerite.css, deliberately unthemed): black-on-transparent and
|
||||
white-on-transparent favicons both stay legible on it, and the text is
|
||||
always dark regardless of the theme's link/text colors. Colors go on the
|
||||
inner elements, so the theme's a / a:hover color rules (which target the
|
||||
anchor) cannot cascade in. */
|
||||
white-on-transparent favicons both stay legible on it. Colors go on the
|
||||
inner elements, so the theme's link color rules cannot cascade in. The
|
||||
padding is matched by negative margins so the chip's content stays
|
||||
exactly where the bare text would sit without the badge — except on the
|
||||
right, which keeps a small positive margin so the next trail item does
|
||||
not abut the chip. */
|
||||
.referer-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
padding-right: 0.45em;
|
||||
border-radius: 0.25rem;
|
||||
/* Fixed line-height: the bar height is then exactly 1.2em + padding =
|
||||
1.6em, so the icon below can be sized to match precisely (an
|
||||
absolutely positioned replaced element cannot derive its height from
|
||||
top/bottom offsets — its aspect ratio wins and bottom is dropped). */
|
||||
line-height: 1.2;
|
||||
padding: 0.2em 0.5em;
|
||||
margin: -0.2em 0.25em -0.2em -0.2em;
|
||||
/* Fully rounded: the bar is exactly 1.6em tall, so a 0.8em radius makes
|
||||
both ends semicircles — a pill, with a full circle around the favicon
|
||||
on the left. */
|
||||
border-radius: 0.8em;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Flush with the badge's top/left/bottom edges: a full-height square (the
|
||||
badge has no padding on those sides), corners clipped by the badge's
|
||||
overflow: hidden border-radius. */
|
||||
/* Room for the absolutely positioned icon (its 1.6em width plus the gap). */
|
||||
.referer-badge.with-icon {
|
||||
padding-left: 1.95em;
|
||||
}
|
||||
|
||||
/* Exactly the bar's height (1.6em, see line-height above), flush to the
|
||||
top/left/bottom borders. The badge does not clip it (no overflow:
|
||||
hidden): the icon may stick out past the bar's rounded corners.
|
||||
Absolute on purpose: an in-flow image is the flex
|
||||
container's first item and would supply the badge's baseline (an image's
|
||||
baseline is its bottom edge), pushing the badge text above the baseline
|
||||
of the trail items that follow. Out of flow, the badge's baseline comes
|
||||
from its text, so baselines match. */
|
||||
.badge-favicon {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.referer-badge > span,
|
||||
.referer-badge > small {
|
||||
/* The link is display: contents: the favicon and label lay out as flex
|
||||
items of the badge itself, and only their actual boxes are clickable. */
|
||||
.badge-link {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Click-to-copy affordance on everything outside the link (the UTM text
|
||||
and the surrounding padding). */
|
||||
.referer-badge.copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.referer-badge span,
|
||||
.referer-badge small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.referer-badge > span { color: var(--badge-text); }
|
||||
.referer-badge > small { color: var(--badge-muted); }
|
||||
.referer-badge span { color: var(--badge-text); }
|
||||
.referer-badge small { color: var(--badge-muted); }
|
||||
</style>
|
||||
|
||||
@@ -257,11 +257,14 @@ const countLabel = (n) =>
|
||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||
}
|
||||
.tmap .txnode {
|
||||
fill: var(--text);
|
||||
stroke: none;
|
||||
/* External source/exit pills: plain white on every theme, with a hairline
|
||||
so the pill stays visible on a white page. */
|
||||
fill: #fff;
|
||||
stroke: var(--line, rgba(128, 128, 128, 0.4));
|
||||
stroke-width: 1;
|
||||
}
|
||||
.tmap .txnode-source { fill: var(--text); }
|
||||
.tmap .txnode-exit { fill: var(--text); }
|
||||
.tmap .txnode-source { fill: #fff; }
|
||||
.tmap .txnode-exit { fill: #fff; }
|
||||
/* Branch lanes: one wide concentric arc per path prefix, running behind
|
||||
the node pills around the fan's circle center; parent levels sit one
|
||||
indent (radius step) outward. Each lane's label follows a short guide
|
||||
@@ -289,15 +292,18 @@ const countLabel = (n) =>
|
||||
stroke: none;
|
||||
}
|
||||
/* Text sizes are viewBox units: they shrink along with the graph on
|
||||
narrow panels. Overlong labels are clipped at the pill border. */
|
||||
narrow panels. Overlong labels are clipped at the pill border. The text
|
||||
is always black, on accent (internal pills) and white (external pills)
|
||||
alike — black stands out from any accent color, so the coloring stays
|
||||
stable across themes and light/dark modes. */
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--bg, Canvas);
|
||||
fill: #000;
|
||||
font-size: 19px;
|
||||
text-anchor: start;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--bg, Canvas);
|
||||
fill: #000;
|
||||
opacity: 0.75;
|
||||
font-size: 15px;
|
||||
text-anchor: middle;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { makeSeries } from './analytics/time.js'
|
||||
import { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
|
||||
import {
|
||||
CHART_H,
|
||||
CHART_W,
|
||||
@@ -35,9 +36,6 @@ const allViews = computed(() => {
|
||||
return all
|
||||
})
|
||||
|
||||
const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range))
|
||||
const viewSeries = computed(() => makeSeries(allViews.value, props.range))
|
||||
|
||||
function freqLabel(unit) {
|
||||
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
||||
}
|
||||
@@ -47,12 +45,6 @@ function axisLabel(unit, ylabel) {
|
||||
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
|
||||
}
|
||||
|
||||
/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */
|
||||
function pastLabel(series) {
|
||||
const oldest = series.at(-1).label.slice(5) // strip "Week "
|
||||
return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}`
|
||||
}
|
||||
|
||||
const now = ref(Date.now())
|
||||
let refreshInterval = null
|
||||
onMounted(() => {
|
||||
@@ -62,8 +54,29 @@ onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
|
||||
const visitChart = computed(() => buildChart(visitSeries.value, now.value))
|
||||
const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
/**
|
||||
* Series for the current range plus, on the day and week views, the
|
||||
* seasonal "typical week" history curve (all history up to now, already
|
||||
* smoothed). Week view: the full Monday-first week. Day view: the rolling
|
||||
* window's bins looked up from the same estimate, labeled by the weekday.
|
||||
*/
|
||||
function withTypical(buckets) {
|
||||
const input = makeSeries(buckets, props.range)
|
||||
if (props.range !== 'day' && props.range !== 'week') return input
|
||||
const estimate = typicalWeek(buckets, now.value)
|
||||
if (!estimate) return input
|
||||
if (props.range === 'week') {
|
||||
return { ...input, typical: { values: [...estimate], label: 'Typical week' } }
|
||||
}
|
||||
const values = input.series[0].points.map((p) => estimate[weekBinIndex(p.t)])
|
||||
const weekday = new Date(now.value).toLocaleDateString(undefined, {
|
||||
weekday: 'long', timeZone: 'UTC',
|
||||
})
|
||||
return { ...input, typical: { values, label: `Typical ${weekday}` } }
|
||||
}
|
||||
|
||||
const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value))
|
||||
const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,8 +88,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
||||
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
||||
<!-- Clip the plot curves to the chart area: past-week overlays can
|
||||
run far above the autoscaled y range, and the svg itself is
|
||||
<!-- Clip the plot curves to the chart area; the svg itself is
|
||||
overflow: visible for the axis labels. -->
|
||||
<clipPath :id="`plot-${c.ylabel}`">
|
||||
<rect x="0" y="0" :width="CHART_W" :height="CHART_H" />
|
||||
@@ -88,17 +100,17 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<g :clip-path="`url(#plot-${c.ylabel})`">
|
||||
<!-- The muted "typical" history curve under the current data. -->
|
||||
<path v-if="c.chart.typical" :d="c.chart.typical.line" class="line past" />
|
||||
<template v-if="c.chart.bars">
|
||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||
<path :d="c.chart.skyline" class="line" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Oldest overlay weeks first so the current week paints on top. -->
|
||||
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :class="{ past: s.past }"
|
||||
:style="{ opacity: s.opacity }" />
|
||||
<path :d="s.line" class="line" />
|
||||
</template>
|
||||
</template>
|
||||
</g>
|
||||
@@ -111,16 +123,17 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
|
||||
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
|
||||
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||
<!-- Week overlay legend, top right inside the plot: current week in
|
||||
accent, one muted specimen for the whole past range. -->
|
||||
<g v-if="c.legend && c.chart.series.length > 1">
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
|
||||
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.series[0].label }}</text>
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
|
||||
<!-- Legend, top right inside the plot: current data in accent
|
||||
(week label, or "Last 24 hours" on the day view) and the
|
||||
typical history curve in muted. -->
|
||||
<g v-if="c.legend && c.chart.typical">
|
||||
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="10" y2="10" class="line" />
|
||||
<text :x="CHART_W - 92" y="10" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.bars ? 'Last 24 hours' : c.chart.series[0].label }}</text>
|
||||
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="25" y2="25"
|
||||
class="line past" style="opacity: 0.6" />
|
||||
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
|
||||
class="leglab">{{ pastLabel(c.chart.series) }}</text>
|
||||
<text :x="CHART_W - 92" y="25" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.typical.label }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -183,12 +196,12 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
|
||||
.chart .area {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chart .bar {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chart .line {
|
||||
|
||||
@@ -181,16 +181,21 @@ export function spline(pts) {
|
||||
export function buildChart(input, now = Date.now()) {
|
||||
if (!input || !input.series.length) return null
|
||||
if (input.unit === '5min') return buildDayChart(input, now)
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit, typical } = input
|
||||
// Values are per-unit rates (hour on the week view, day on month+); the
|
||||
// y max is derived from the *smoothed* curves so random single-bucket
|
||||
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
|
||||
// detector thresholds are count-based), the result is scaled back to rates.
|
||||
const smoothed = series.map((s) =>
|
||||
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
|
||||
// Scale from the current/primary series only; older overlay weeks are drawn
|
||||
// with the same scale and allowed to overflow if they are busier.
|
||||
const highest = Math.max(0, ...smoothed[0])
|
||||
// The "typical week" seasonal estimate is already smooth: one value per
|
||||
// bin spanning the full week (future included), drawn in the muted color.
|
||||
const typicalRates = typical
|
||||
? [...typical.values].map((v) => v * rate)
|
||||
: null
|
||||
// Scale from the current series plus the typical curve; both are smooth,
|
||||
// and neither should be clipped in normal traffic.
|
||||
const highest = Math.max(0, ...smoothed[0], ...(typicalRates || []))
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
@@ -205,6 +210,12 @@ export function buildChart(input, now = Date.now()) {
|
||||
area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null,
|
||||
}
|
||||
})
|
||||
let typicalLine = null
|
||||
if (typicalRates) {
|
||||
const binMs = (t1 - t0) / typicalRates.length
|
||||
const pts = typicalRates.map((v, i) => ({ x: x(t0 + i * binMs), y: y(v) }))
|
||||
typicalLine = { line: spline(pts), label: typical.label }
|
||||
}
|
||||
// Major (labeled) and minor (hairline) y grid ticks.
|
||||
const majors = []
|
||||
const minors = []
|
||||
@@ -256,16 +267,19 @@ export function buildChart(input, now = Date.now()) {
|
||||
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw
|
||||
* counts; the skyline uses a projected full-bucket value for the still-open
|
||||
* final bucket. The y scale is derived from the projected skyline maximum.
|
||||
* The optional "typical day" curve (per-bin counts aligned to the window's
|
||||
* bins, cut from the typical-week estimate) overlays the bars as a smooth
|
||||
* muted line and also feeds the y scale.
|
||||
*/
|
||||
export function buildDayChart(input, now = Date.now()) {
|
||||
const { series, t0, t1 } = input
|
||||
const { series, t0, t1, typical } = input
|
||||
const points = series[0]?.points || []
|
||||
const n = points.length
|
||||
if (!n) return null
|
||||
@@ -285,7 +299,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const share = elapsed / bucketMs
|
||||
return p.count + prevRaw * (1 - share)
|
||||
})
|
||||
const highest = Math.max(0, ...projected)
|
||||
const highest = Math.max(0, ...projected, ...(typical ? typical.values : []))
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
|
||||
@@ -313,6 +327,15 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
}
|
||||
}
|
||||
|
||||
let typicalLine = null
|
||||
if (typical) {
|
||||
const pts = points.map((p, i) => ({
|
||||
x: (i + 0.5) * bucketWidth,
|
||||
y: y(typical.values[i] || 0),
|
||||
}))
|
||||
typicalLine = { line: spline(pts), label: typical.label }
|
||||
}
|
||||
|
||||
const majors = []
|
||||
const minors = []
|
||||
const nMajor = Math.round(max / step)
|
||||
@@ -338,7 +361,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||
|
||||
@@ -180,22 +180,45 @@ function stepOf(path, titles) {
|
||||
/**
|
||||
* Badge data combining a visit's/crawler's external referer origin with the
|
||||
* visit's UTM tags: the origin as the badge link/label (the favicon is
|
||||
* looked up by origin in the component), the known UTM values as a short
|
||||
* inline summary, and a one-fact-per-line tooltip — the full origin URL on
|
||||
* the first line, then every ``utm_*=value`` pair. Null when there is no
|
||||
* external referer and no UTM tag (a plain direct visit).
|
||||
* looked up by origin in the component), a compact UTM summary (the few
|
||||
* most informative values) as ``utm`` with the full ``utm_*=value`` list
|
||||
* as ``utmCopy`` for click-to-copy, and a one-fact-per-line tooltip — the
|
||||
* full origin URL on the first line, then every ``utm_*=value`` pair.
|
||||
* Null when there is no external referer and no UTM tag (a plain direct
|
||||
* visit).
|
||||
*/
|
||||
function refererBadgeOf(referer, titles, utmTags = {}) {
|
||||
const step = stepOf(referer, titles)
|
||||
const external = step?.external ? step : null
|
||||
const known = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||
const utm = known.map((k) => utmTags[k]).filter(Boolean).join(' · ')
|
||||
// Compact UTM summary, in display order source, campaign, content, term:
|
||||
// source only when no referer is known (it just repeats where the visitor
|
||||
// came from), content only as a stand-in when there is no term. The
|
||||
// remaining tags (medium and any nonstandard utm_*) fill in only when
|
||||
// fewer than three of these more useful items exist — and never when a
|
||||
// term is present (the term alone says enough). The tooltip keeps
|
||||
// every tag, one pair per line.
|
||||
const useful = []
|
||||
if (utmTags.utm_source && !referer) useful.push(utmTags.utm_source)
|
||||
if (utmTags.utm_campaign) useful.push(utmTags.utm_campaign)
|
||||
if (utmTags.utm_content && !utmTags.utm_term) useful.push(utmTags.utm_content)
|
||||
if (utmTags.utm_term) useful.push(utmTags.utm_term)
|
||||
const rest = useful.length < 3 && !utmTags.utm_term
|
||||
? Object.keys(utmTags)
|
||||
.filter((k) => !['utm_source', 'utm_campaign', 'utm_content', 'utm_term'].includes(k))
|
||||
.map((k) => utmTags[k])
|
||||
.filter(Boolean)
|
||||
: []
|
||||
const utm = [...useful, ...rest].join(' · ')
|
||||
if (!external && !utm) return null
|
||||
const utmCopy = Object.entries(utmTags)
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join('\n')
|
||||
return {
|
||||
href: external?.origin || '',
|
||||
label: external?.slug || '',
|
||||
origin: external?.origin || '',
|
||||
utm,
|
||||
utmCopy,
|
||||
title: [
|
||||
...(external ? [external.origin] : []),
|
||||
...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Seasonal "typical week" estimate from the full visit history.
|
||||
*
|
||||
* Port of the seasonal.py demo algorithm: the whole smoothed 5-minute
|
||||
* history is collapsed onto a weekly grid with exponential decay over age —
|
||||
* a fast kernel (half-life 7 days) for the average time-of-day pattern and
|
||||
* a slow one (half-life 42 days) for per-weekday deviations from it. The
|
||||
* deviation is shrunk by the effective number of weeks behind each bin
|
||||
* (n_eff / (n_eff + 3)), so with little history the estimate falls back to
|
||||
* the common daily pattern and weekday character emerges as data accrues.
|
||||
* Bins before the first recorded bucket are treated as missing.
|
||||
*/
|
||||
|
||||
import { DAY, MIN5, mondayUTC, rawTimes } from './time.js'
|
||||
import { smooth } from './chart.js'
|
||||
|
||||
export const BINS_PER_DAY = 288
|
||||
export const BINS_PER_WEEK = 7 * BINS_PER_DAY
|
||||
|
||||
// History cap: at 180 days the slow kernel's weight is 2^(-180/42) ≈ 5%
|
||||
// (and the fast kernel's utterly negligible), so older data cannot move
|
||||
// this noisy estimate — skipping it keeps the smoothing pass O(1).
|
||||
const MAX_HISTORY_DAYS = 180
|
||||
|
||||
/**
|
||||
* Estimate the typical week from a dense 5-minute count series (oldest
|
||||
* first; non-finite values count as missing). endWeekBin is the week bin
|
||||
* (Monday-first) just past the last sample. Returns BINS_PER_WEEK counts
|
||||
* per 5-minute bin, starting Monday 00:00.
|
||||
*/
|
||||
export function seasonalCurve(counts, {
|
||||
endWeekBin,
|
||||
binsPerDay = BINS_PER_DAY,
|
||||
recentHalfLife = 7,
|
||||
weekdayHalfLife = 42,
|
||||
shrinkWeeks = 3,
|
||||
} = {}) {
|
||||
const n = counts.length
|
||||
const binsPerWeek = 7 * binsPerDay
|
||||
|
||||
const recentW = new Float64Array(binsPerDay)
|
||||
const recentX = new Float64Array(binsPerDay)
|
||||
const dayW = new Float64Array(binsPerDay)
|
||||
const dayX = new Float64Array(binsPerDay)
|
||||
const weekW = new Float64Array(binsPerWeek)
|
||||
const weekX = new Float64Array(binsPerWeek)
|
||||
const weekW2 = new Float64Array(binsPerWeek)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = counts[i]
|
||||
if (!Number.isFinite(x)) continue
|
||||
let weekBin = (endWeekBin - n + i) % binsPerWeek
|
||||
if (weekBin < 0) weekBin += binsPerWeek
|
||||
const dayBin = weekBin % binsPerDay
|
||||
const ageDays = (n - 1 - i) / binsPerDay
|
||||
const recent = 2 ** (-ageDays / recentHalfLife)
|
||||
const slow = 2 ** (-ageDays / weekdayHalfLife)
|
||||
recentW[dayBin] += recent
|
||||
recentX[dayBin] += recent * x
|
||||
dayW[dayBin] += slow
|
||||
dayX[dayBin] += slow * x
|
||||
weekW[weekBin] += slow
|
||||
weekX[weekBin] += slow * x
|
||||
weekW2[weekBin] += slow * slow
|
||||
}
|
||||
|
||||
const estimate = new Float64Array(binsPerWeek)
|
||||
for (let wb = 0; wb < binsPerWeek; wb++) {
|
||||
const db = wb % binsPerDay
|
||||
const recentMean = recentW[db] > 0 ? recentX[db] / recentW[db] : NaN
|
||||
const dayMean = dayW[db] > 0 ? dayX[db] / dayW[db] : NaN
|
||||
const weekMean = weekW[wb] > 0 ? weekX[wb] / weekW[wb] : 0
|
||||
const nEff = weekW2[wb] > 0 ? (weekW[wb] * weekW[wb]) / weekW2[wb] : 0
|
||||
const shrink = nEff / (nEff + shrinkWeeks)
|
||||
const base = Number.isFinite(recentMean)
|
||||
? recentMean
|
||||
: Number.isFinite(dayMean) ? dayMean : 0
|
||||
const deviation = Number.isFinite(dayMean) ? weekMean - dayMean : 0
|
||||
estimate[wb] = base + shrink * deviation
|
||||
}
|
||||
return estimate
|
||||
}
|
||||
|
||||
/** Week bin (0 = Monday 00:00–00:05 UTC) containing timestamp t. */
|
||||
export function weekBinIndex(t) {
|
||||
return Math.floor((t - mondayUTC(t)) / MIN5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Typical-week estimate from sparse 5-minute buckets, using history up to
|
||||
* tEnd (default now): bins are densified from the first recorded bucket
|
||||
* (capped at MAX_HISTORY_DAYS back), smoothed with the same Gaussian the
|
||||
* week view uses, then folded by seasonalCurve. Returns BINS_PER_WEEK
|
||||
* counts per 5-minute bin starting Monday, or null when there is less than
|
||||
* a day of history.
|
||||
*/
|
||||
export function typicalWeek(buckets, tEnd = Date.now()) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const end = Math.floor(tEnd / MIN5) * MIN5
|
||||
const start = Math.max(Math.min(...times), end - MAX_HISTORY_DAYS * DAY)
|
||||
const n = Math.floor((end - start) / MIN5)
|
||||
if (n < BINS_PER_DAY) return null
|
||||
const counts = new Array(n)
|
||||
for (let i = 0; i < n; i++) counts[i] = raw[start + i * MIN5] || 0
|
||||
const smoothed = smooth(counts, 5, 60)
|
||||
return seasonalCurve(smoothed, { endWeekBin: weekBinIndex(end) })
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Raw data comes as sparse 5-minute buckets; the range picks the x window
|
||||
* and a coarser bucket size to keep point counts sane. The week range is
|
||||
* aligned to Monday 00:00 UTC and overlays previous weeks' curves (fading
|
||||
* with age), so weekly patterns compare directly.
|
||||
* aligned to Monday 00:00 UTC; a "typical week" seasonal estimate
|
||||
* (seasonal.js) is overlaid on the week and day views by the chart builder.
|
||||
*/
|
||||
|
||||
export const MIN5 = 5 * 60e3
|
||||
@@ -51,60 +51,24 @@ export function sumRange(raw, t0, t1) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One series per overlaid week: [this week, 1 week ago, ...], at native
|
||||
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
|
||||
* recorded data at all). Each older week's timestamps are shifted forward
|
||||
* onto the current week's axis so all curves overlay inside the plot.
|
||||
* The current week is truncated at the current bucket
|
||||
* — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* The current week at native 5-minute resolution, truncated at the current
|
||||
* bucket — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h".
|
||||
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
|
||||
* Previous weeks are no longer overlaid; the "typical week" seasonal
|
||||
* estimate (seasonal.js) takes their place as the history reference.
|
||||
*/
|
||||
export function weeklySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
const now = Date.now()
|
||||
const thisMonday = mondayUTC(now)
|
||||
if (!times.length) {
|
||||
const points = []
|
||||
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
for (let t = thisMonday; t < end; t += MIN5) {
|
||||
points.push({ t, count: 0 })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
|
||||
t0: thisMonday,
|
||||
t1: thisMonday + WEEK,
|
||||
rate: HOUR / MIN5,
|
||||
binMinutes: 5,
|
||||
unitMinutes: 60,
|
||||
unit: 'hour',
|
||||
}
|
||||
}
|
||||
const oldest = Math.min(...times)
|
||||
// Weeks back as far as the data reaches: difference in Monday indices.
|
||||
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
|
||||
const count = Math.min(available, 8)
|
||||
const out = []
|
||||
for (let back = 0; back < count; back++) {
|
||||
const start = thisMonday - back * WEEK
|
||||
const end = back === 0
|
||||
? Math.min(start + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
: start + WEEK
|
||||
const points = []
|
||||
for (let t = start; t < end; t += MIN5) {
|
||||
points.push({ t: t + back * WEEK, count: raw[t] || 0 })
|
||||
}
|
||||
out.push({
|
||||
points,
|
||||
label: `Week ${isoWeek(start)}`,
|
||||
opacity: Math.max(0.15, 1 - back * 0.25),
|
||||
past: back > 0,
|
||||
area: back === 0,
|
||||
})
|
||||
const points = []
|
||||
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
for (let t = thisMonday; t < end; t += MIN5) {
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
}
|
||||
return {
|
||||
series: out,
|
||||
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
|
||||
t0: thisMonday,
|
||||
t1: thisMonday + WEEK,
|
||||
rate: HOUR / MIN5,
|
||||
|
||||
@@ -26,14 +26,15 @@
|
||||
/* Selection fill for page text and the CodeMirror editors; themes
|
||||
override when the accent tint clashes with accent-colored text. */
|
||||
--selection-bg: color-mix(var(--accent) 30%, transparent);
|
||||
/* Referer-badge chip in the analytics viewer: a fixed neutral palette,
|
||||
/* Referer-badge chip in the analytics viewer: a translucent neutral wash,
|
||||
deliberately NOT themed — the chip sits behind transparent favicons, so
|
||||
black-on-transparent and white-on-transparent glyphs must both stay
|
||||
legible on every theme (dark greys kill black glyphs, pure white kills
|
||||
white ones); its text likewise stays dark on any theme. */
|
||||
--badge-bg: #c9d1d9;
|
||||
--badge-text: #1f2328;
|
||||
--badge-muted: #59636e;
|
||||
legible on every theme (a slight whitening keeps black glyphs readable
|
||||
on dark bars without a glaring solid-white chip). Being translucent, it
|
||||
takes the page's tone, so its text follows the theme's colors. */
|
||||
--badge-bg: #aaaaaa44;
|
||||
--badge-text: var(--text);
|
||||
--badge-muted: var(--muted);
|
||||
/* Code highlighting palette, consumed by pygments.css: complete light and
|
||||
dark sets (background included), resolved by light-dark() from the
|
||||
used color-scheme. A theme picks a set simply by declaring
|
||||
@@ -480,19 +481,20 @@ main {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Child-entry card stacks: a category page (and the content-less category
|
||||
404) lists its published children after the markdown content — one column
|
||||
per child, the child's whole subtree flattened into the column in menu
|
||||
order (see _cards in views.py). The row bleeds to full page width
|
||||
(div.wide): the columns first grow to fill it, then shrink rather than
|
||||
wrap. Every card has the same fixed 16/10 shape, covered entirely by the
|
||||
/* Child-entry cards: a category page (and the content-less category
|
||||
404) lists its published children after the markdown content — one
|
||||
card per child (a child without a page of its own is represented by
|
||||
its first leaf page; see _cards in views.py). The row bleeds to full
|
||||
page width (div.wide): the cards first grow to fill it, then shrink
|
||||
rather than wrap. Every card has the same fixed 16/10 shape, covered
|
||||
entirely by the
|
||||
page's share image (og:image heuristics, as a background — a gradient
|
||||
placeholder when it has none) with the title overlaid on a translucent
|
||||
band at the bottom. The card is one <a> holding only phrasing-level
|
||||
spans; the spans lay out as blocks. */
|
||||
.cards {
|
||||
display: flex;
|
||||
/* Stacks stop growing at their cap; center the row in the bleed then. */
|
||||
/* Cards stop growing at their cap; center the row in the bleed then. */
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-top: 2.5rem;
|
||||
@@ -501,18 +503,7 @@ main {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
.cards .stack {
|
||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||
cards), shrink (not wrap) when there are too many. */
|
||||
flex: 1 1 0;
|
||||
max-width: 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Phones: the columns stack vertically instead of shrinking to slivers.
|
||||
/* Phones: the cards stack vertically instead of shrinking to slivers.
|
||||
Text-only cards (gradient cover + description) then fit their content —
|
||||
the fixed 16/10 shape only makes sense for image covers. */
|
||||
@media (max-width: 48rem) {
|
||||
@@ -526,12 +517,16 @@ main {
|
||||
}
|
||||
|
||||
.card {
|
||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||
cards), shrink (not wrap) when there are too many. */
|
||||
flex: 1 1 0;
|
||||
max-width: 24rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 16 / 10;
|
||||
/* Allow shrinking below the text's min-content width: without this the
|
||||
text cards hold their stack wider than the image-only stacks. */
|
||||
text cards hold their row wider than the image-only cards. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -628,7 +628,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
wsQueue.push(msg);
|
||||
}
|
||||
|
||||
function ping({ to, fr = currentPath, read = 0 } = {}) {
|
||||
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
|
||||
// Reading-time updates from the analytics page itself are not tracked
|
||||
// (/_a is admin machinery; the server would reject the path anyway).
|
||||
if (!to && currentPath === "/_a") return;
|
||||
@@ -637,7 +637,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
if (to) msg.to = to;
|
||||
const secs = Math.round(read / 1000);
|
||||
if (secs > 0) msg.read = secs;
|
||||
const lang = document.documentElement.lang;
|
||||
// The rendered language: normally the live <html lang>, but a language
|
||||
// switch passes it explicitly — the swap that updates <html> runs inside
|
||||
// the view-transition callback, after the switch ping goes out.
|
||||
lang = lang || document.documentElement.lang;
|
||||
if (lang) msg.lang = lang;
|
||||
if (!msg.to && !msg.read) return;
|
||||
report(msg);
|
||||
@@ -818,9 +821,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
await load(currentPath, false);
|
||||
scrollTo(0, y);
|
||||
// Log the switch as a trail event in the new language (load() updated
|
||||
// <html lang>): the ping matches the switch's GET server-side, so it is
|
||||
// not misclassified as a crawler hit.
|
||||
ping({ to: currentPath });
|
||||
// <html lang>, but possibly inside a still-pending view transition, so
|
||||
// pass the tag explicitly): the ping matches the switch's GET
|
||||
// server-side, so it is not misclassified as a crawler hit.
|
||||
ping({ to: currentPath, lang: tag });
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
|
||||
@@ -55,6 +55,16 @@ def main() -> None:
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
reload=Path(__file__).parent if env.dev else False,
|
||||
# Partial log config, merged over uvicorn's default by fastapi-vue:
|
||||
# root prints at WARNING in production / INFO in dev. Keep our own
|
||||
# loggers audible in production, and silence httpx's per-request INFO
|
||||
# (tracking._schedule_favicon_fetch logs its own one-line summary).
|
||||
log_config={
|
||||
"loggers": {
|
||||
"pagerite": {"level": "INFO"},
|
||||
"httpx": {"level": "WARNING"},
|
||||
}
|
||||
},
|
||||
**run_args,
|
||||
)
|
||||
|
||||
|
||||
+25
-1
@@ -19,6 +19,7 @@ from fastapi import (
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from html5tagger import E
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pagerite import i18n, views
|
||||
@@ -495,6 +496,11 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
markdown = msg.get("markdown", "")
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
# Expand {cards} like page_content does, so the preview
|
||||
# shows real cards, not the literal tag. No translation
|
||||
# context: the preview has no lang of its own, so cards
|
||||
# render in their originals.
|
||||
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
|
||||
rendered = render(
|
||||
markdown,
|
||||
path,
|
||||
@@ -511,12 +517,30 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
if node
|
||||
else None
|
||||
),
|
||||
directives=(
|
||||
{
|
||||
"cards": lambda args, _env: views._cards_tag(
|
||||
data.menu, data, node, path, args
|
||||
)
|
||||
}
|
||||
if node is not None and has_cards_tag
|
||||
else None
|
||||
),
|
||||
)
|
||||
html = rendered.html
|
||||
if node is not None and not has_cards_tag:
|
||||
# Without a {cards} tag page_content appends the
|
||||
# children's cards after the content — the preview
|
||||
# replaces the whole article, so include them here.
|
||||
doc = E.div
|
||||
with doc:
|
||||
views._cards(doc, data.menu, data, node, path)
|
||||
html += str(doc)
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "html",
|
||||
"path": path,
|
||||
"html": rendered.html,
|
||||
"html": html,
|
||||
# Column-layout flag: the preview toggles the
|
||||
# article's .multicol class and swaps in the
|
||||
# segmented (.colseg/.cols) article html.
|
||||
|
||||
+5
-10
@@ -87,21 +87,16 @@ def select_language(
|
||||
|
||||
1. ``?lang=`` wins when a translation exists for it (otherwise falls
|
||||
through to the header logic).
|
||||
2. The original language anywhere in the header list wins — an AI
|
||||
translation is strictly worse than the original for anyone who has
|
||||
English configured at all.
|
||||
3. Otherwise the first header language with an available translation.
|
||||
4. Fall back to the original.
|
||||
2. Otherwise the first header language that can be served — the
|
||||
original, or one with an available translation.
|
||||
3. Fall back to the original.
|
||||
"""
|
||||
if query_lang:
|
||||
tag = base_tag(query_lang)
|
||||
if tag == original or (tag and is_available(tag)):
|
||||
return tag
|
||||
langs = parse_accept_language(accept_language or "")
|
||||
if original in langs:
|
||||
return original
|
||||
for lang in langs:
|
||||
if lang != original and is_available(lang):
|
||||
for lang in parse_accept_language(accept_language or ""):
|
||||
if lang == original or is_available(lang):
|
||||
return lang
|
||||
return original
|
||||
|
||||
|
||||
+79
-5
@@ -56,9 +56,15 @@ becomes a block `<figure>` — with `<figcaption>` when it has a title.
|
||||
Images inline with other content stay plain inline `<img>`, as does raw
|
||||
`<img>` HTML written by the author. Positioning is done with attribute
|
||||
classes, e.g. `{.right}`.
|
||||
|
||||
A lone `{name}` or `{name: args}` line is a block directive, expanded by
|
||||
the caller through render(directives=...) — `{dates}` (built in) expands
|
||||
to the article's dateline, `{cards}` / `{cards: path ...}` to card rows
|
||||
of other pages (views.py). Unresolved tags render as the literal source.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -505,6 +511,64 @@ def anchor_ids(text: str, title: str | None = None) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
#: A lone {...} paragraph: a block directive like {dates} or
|
||||
#: {cards: docs/* news} — name, then optional ":"-separated argument text.
|
||||
_DIRECTIVE_RE = re.compile(r"\{([a-z][a-z0-9_-]*)(?::([^{}\n]*))?\}")
|
||||
|
||||
|
||||
def _directives(state) -> None:
|
||||
"""Turn lone ``{name}`` / ``{name: args}`` paragraphs into directive tokens.
|
||||
|
||||
The expansion is not markdown.py's business: _directive_rule delegates
|
||||
to the resolvers render() put in env["directives"], falling back to the
|
||||
literal source when the tag is unknown in the context (e.g. the editor
|
||||
preview of a page that does not exist yet). The ``cards`` directive gets .wide so it
|
||||
stands alone as a full-width block outside the column segments (the
|
||||
card markup never flows in columns). Runs on the render instance only —
|
||||
the verbatim parser keeps the plain paragraph so segments/chunks see
|
||||
the placeholder source.
|
||||
"""
|
||||
tokens = state.tokens
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
if (
|
||||
i + 2 < len(tokens)
|
||||
and tokens[i].type == "paragraph_open"
|
||||
and tokens[i + 1].type == "inline"
|
||||
and tokens[i + 2].type == "paragraph_close"
|
||||
):
|
||||
inline = tokens[i + 1]
|
||||
children = inline.children or []
|
||||
if len(children) == 1 and children[0].type == "text":
|
||||
m = _DIRECTIVE_RE.fullmatch(children[0].content.strip())
|
||||
if m:
|
||||
token = Token("directive", "", 0)
|
||||
token.level = tokens[i].level
|
||||
token.map = tokens[i].map
|
||||
token.content = m.group(0)
|
||||
token.meta = {"name": m.group(1), "args": (m.group(2) or "").strip()}
|
||||
if m.group(1) == "cards":
|
||||
token.attrSet("class", "wide")
|
||||
out.append(token)
|
||||
i += 3
|
||||
continue
|
||||
out.append(tokens[i])
|
||||
i += 1
|
||||
state.tokens = out
|
||||
|
||||
|
||||
def _directive_rule(self: RendererHTML, tokens, idx: int, options, env: dict) -> str:
|
||||
"""Render a directive token via env["directives"][name](args, env);
|
||||
unresolved tags render as the literal source paragraph."""
|
||||
token = tokens[idx]
|
||||
resolver = (env.get("directives") or {}).get(token.meta["name"])
|
||||
html = resolver(token.meta["args"], env) if resolver else None
|
||||
if html is None:
|
||||
return f"<p>{escapeHtml(token.content)}</p>\n"
|
||||
return html + "\n"
|
||||
|
||||
|
||||
def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
"""A fully configured parser. The module-level ``md`` (below) is the
|
||||
render instance; ``verbatim=True`` builds the segmentation instance for
|
||||
@@ -539,6 +603,7 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
)
|
||||
parser.add_render_rule("image", _image_rule)
|
||||
parser.add_render_rule("fence", _fence_rule)
|
||||
parser.add_render_rule("directive", _directive_rule)
|
||||
# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
|
||||
parser.options["alerts"] = True
|
||||
# Block attrs must be stripped before the typographer curlifies their quotes.
|
||||
@@ -548,6 +613,8 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
parser.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||
parser.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||
parser.core.ruler.push("heading_ids", _heading_ids)
|
||||
if not verbatim:
|
||||
parser.core.ruler.push("directives", _directives)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -661,6 +728,7 @@ def render(
|
||||
modified: datetime | None = None,
|
||||
title: str | None = None,
|
||||
anchors_from: tuple[str, str] | None = None,
|
||||
directives: dict[str, Callable[[str, dict], str | None]] | None = None,
|
||||
) -> Rendered:
|
||||
"""Render Markdown text to the article body's HTML and layout flags.
|
||||
|
||||
@@ -682,11 +750,19 @@ def render(
|
||||
classes.
|
||||
|
||||
A ``{dates}`` line expands to the article's published/updated dateline
|
||||
(needs ``created``/``modified``; left as-is in contexts without them,
|
||||
e.g. the editor preview). Position is the author's choice — typically
|
||||
(needs ``created``/``modified``). Block directives in general — a lone
|
||||
``{name}`` or ``{name: args}`` line — are expanded by the resolvers
|
||||
passed as ``directives`` (name → (args, env) → HTML or None), with
|
||||
``dates`` built in when ``created`` is given; unresolved tags render as
|
||||
the literal source (e.g. in the editor preview of a not-yet-created
|
||||
page).
|
||||
Position is the author's choice — the dateline typically goes
|
||||
right after the article's h1.
|
||||
"""
|
||||
env = {"page_path": page_path, "line_offset": 0}
|
||||
directives = dict(directives or {})
|
||||
if created is not None:
|
||||
directives.setdefault("dates", lambda _args, _env: _dateline(created, modified))
|
||||
env = {"page_path": page_path, "line_offset": 0, "directives": directives}
|
||||
if anchors_from is not None:
|
||||
env["anchor_ids"] = anchor_ids(*anchors_from)
|
||||
if title and not has_h1(text):
|
||||
@@ -732,8 +808,6 @@ def render(
|
||||
html = marked
|
||||
parts.append(f'<div class="colseg{cols}">{html}</div>')
|
||||
html = "".join(parts)
|
||||
if created is not None and "<p>{dates}</p>" in html:
|
||||
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
||||
return Rendered(html, total > MULTICOL_TEXT)
|
||||
|
||||
|
||||
|
||||
@@ -35,10 +35,6 @@ from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# httpx logs every request at INFO (e.g. the favicon fetches below); our own
|
||||
# one-line summary in _schedule_favicon_fetch replaces that noise.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Live WebSocket clients for the analytics stream.
|
||||
|
||||
+134
-23
@@ -791,7 +791,10 @@ def page_content(
|
||||
"""Render the contents of the #main element for a page.
|
||||
|
||||
A page with published children (a category page) lists them as cards
|
||||
after the markdown content. With a translation, its Markdown goes
|
||||
after the markdown content — unless the content has a ``{cards}`` tag,
|
||||
which places card rows itself (bare: the children; with paths:
|
||||
those pages, ``path/*`` their children, ``path/**`` all descendants),
|
||||
one row per tag. With a translation, its Markdown goes
|
||||
through the same render pipeline; missing pieces (markdown=None, absent
|
||||
title entries) fall back to the original. ``lang`` feeds the cards'
|
||||
per-target localization.
|
||||
@@ -813,8 +816,24 @@ def page_content(
|
||||
)
|
||||
# The title is injected into the markdown (as # title when it has no
|
||||
# h1 of its own), so title and content render as one article.
|
||||
# A {cards} tag places the card rows itself (possibly several);
|
||||
# without one the children are appended after the content as before.
|
||||
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
|
||||
directives = None
|
||||
if has_cards_tag:
|
||||
directives = {
|
||||
"cards": lambda args, _env: _cards_tag(
|
||||
menu, data, node, path, args, translation, link_lang, lang
|
||||
)
|
||||
}
|
||||
rendered = render(
|
||||
content, path, node.created, node.modified, title=title, anchors_from=anchors_from
|
||||
content,
|
||||
path,
|
||||
node.created,
|
||||
node.modified,
|
||||
title=title,
|
||||
anchors_from=anchors_from,
|
||||
directives=directives,
|
||||
)
|
||||
# Long articles get .multicol: the article column cap lifts (see the
|
||||
# #content grid in pagerite.css) and the .cols segments lay out in at
|
||||
@@ -823,10 +842,25 @@ def page_content(
|
||||
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||
with doc:
|
||||
doc(HTML(rendered.html))
|
||||
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||
if not has_cards_tag:
|
||||
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||
return HTML(str(doc))
|
||||
|
||||
|
||||
def _represent(node: Node, path: str) -> tuple[str, Node] | None:
|
||||
"""The (path, node) a card for this menu item points at: the item
|
||||
itself when it has a page, else its first published leaf page,
|
||||
recursively — the same logic as nav links (first_leaf)."""
|
||||
if node.chunks:
|
||||
return path, node
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
if child.published:
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
if r := _represent(child, cpath):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def _cards(
|
||||
doc,
|
||||
menu: dict[str, Node],
|
||||
@@ -837,38 +871,107 @@ def _cards(
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> None:
|
||||
"""Card stacks of the node's published children (nothing when childless).
|
||||
"""Cards of the node's published children (nothing when childless).
|
||||
|
||||
One column per direct child, all in a single full-width row (the .wide
|
||||
breakout): the columns grow to fill the page and shrink rather than
|
||||
wrap. A column holds the child's whole subtree flattened in menu order
|
||||
— nesting levels are not split out — starting with the first page that
|
||||
has actual content (the child itself when it does, its first leaf
|
||||
otherwise, recursively). Each card is one <a> showing the page's share
|
||||
One card per direct child, all in a single full-width row (the .wide
|
||||
breakout): the cards grow to fill the page and shrink rather than
|
||||
wrap. A child without a page of its own is represented by its first
|
||||
leaf page (_represent, the nav-link logic). Each card is one <a>
|
||||
showing the page's share
|
||||
image (the same heuristics as og:image) as the cover and its title;
|
||||
image-less cards get a gradient cover and also show the description.
|
||||
Only phrasing-level elements (spans) go inside the <a>: as a formatting
|
||||
element it would be cloned by the HTML parser around any block-level
|
||||
child, splitting one card into several links.
|
||||
"""
|
||||
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||
items = [
|
||||
r
|
||||
for s, c in sorted_nodes(node.children)
|
||||
if c.published
|
||||
for r in [_represent(c, f"{path}/{s}" if path else s)]
|
||||
if r
|
||||
]
|
||||
if not items:
|
||||
return
|
||||
with doc.div(class_="cards wide"):
|
||||
for slug, child in items:
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
entries = list(_walk(child, cpath))
|
||||
if not entries:
|
||||
continue
|
||||
with doc.div(class_="stack"):
|
||||
for epath, enode in entries:
|
||||
_card(doc, data, enode, epath, translation, link_lang, lang)
|
||||
for cpath, cnode in items:
|
||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
||||
|
||||
|
||||
#: A lone {cards} or {cards: ...} line in the markdown: card rows placed
|
||||
#: by the author. Any such tag suppresses the automatic end-of-page cards.
|
||||
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.M)
|
||||
|
||||
|
||||
def _cards_tag(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
node: Node,
|
||||
path: str,
|
||||
args: str,
|
||||
translation: Translation | None = None,
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> str:
|
||||
"""Expand a ``{cards}`` directive to a card row (the same markup as
|
||||
_cards, so author-placed cards look like category cards).
|
||||
|
||||
A bare ``{cards}`` lists the page's own published children — what
|
||||
page_content appends when the tag is absent — or, on the front page,
|
||||
the other top-level pages (the front page is a top-level item itself,
|
||||
not the parent of the others). Arguments are space-separated page
|
||||
paths: a plain path renders that page alone (never its children),
|
||||
``path/*`` its published children and ``path/**`` all published
|
||||
descendant pages. One card per item; a page-less item is represented
|
||||
by its first leaf page (_represent). Unresolvable paths are skipped;
|
||||
a tag that ends up with nothing renders as nothing.
|
||||
"""
|
||||
items: list[tuple[str, Node]] = []
|
||||
specs = args.split()
|
||||
|
||||
def children(base: str, parent: Node):
|
||||
for s, c in sorted_nodes(parent.children):
|
||||
if c.published:
|
||||
if r := _represent(c, f"{base}/{s}" if base else s):
|
||||
items.append(r)
|
||||
|
||||
if not specs:
|
||||
if path:
|
||||
children(path, node)
|
||||
else:
|
||||
for s, c in sorted_nodes(menu):
|
||||
if c.published and s:
|
||||
if r := _represent(c, s):
|
||||
items.append(r)
|
||||
else:
|
||||
for spec in specs:
|
||||
spec = spec.strip("/")
|
||||
if spec.endswith("/**"):
|
||||
base = spec[:-3].rstrip("/")
|
||||
if chain := resolve(menu, base):
|
||||
for s, c in sorted_nodes(chain[-1].children):
|
||||
if c.published:
|
||||
items.extend(_walk(c, f"{base}/{s}" if base else s))
|
||||
elif spec.endswith("/*"):
|
||||
base = spec[:-2].rstrip("/")
|
||||
if chain := resolve(menu, base):
|
||||
children(base, chain[-1])
|
||||
elif chain := resolve(menu, spec):
|
||||
if r := _represent(chain[-1], spec):
|
||||
items.append(r)
|
||||
if not items:
|
||||
return ""
|
||||
doc = E.div(class_="cards wide")
|
||||
with doc:
|
||||
for cpath, cnode in items:
|
||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
||||
return str(doc)
|
||||
|
||||
|
||||
def _walk(node: Node, path: str):
|
||||
"""Published content pages of a subtree, pre-order in menu order: the
|
||||
node itself first when it has content (the stack's landing card), then
|
||||
its descendants (content-less nodes contribute only their subtree)."""
|
||||
node itself first when it has content, then its descendants
|
||||
(content-less nodes contribute only their subtree)."""
|
||||
if node.chunks:
|
||||
yield path, node
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
@@ -885,7 +988,7 @@ def _card(
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> None:
|
||||
"""One card in a stack: cover + title, plus the description when the
|
||||
"""One card: cover + title, plus the description when the
|
||||
page has no image (its card shows a gradient cover instead).
|
||||
|
||||
The card text localizes per target article where that page is
|
||||
@@ -898,7 +1001,15 @@ def _card(
|
||||
md = node_markdown(data, node) or ""
|
||||
if lang and lang in node.langs:
|
||||
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||
html = render(md, path, node.created, node.modified).html
|
||||
html = render(
|
||||
md,
|
||||
path,
|
||||
node.created,
|
||||
node.modified,
|
||||
# Card heuristics only mine the prose: nested {cards} rows
|
||||
# would just be noise in the description extraction.
|
||||
directives={"cards": lambda _args, _env: ""},
|
||||
).html
|
||||
image, _ = _media(html)
|
||||
if not image:
|
||||
description = _description(html, 150)
|
||||
|
||||
+3
-3
@@ -17,15 +17,15 @@ readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"blake3>=1.0.9",
|
||||
"fastapi-vue~=1.6.1",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.141.1",
|
||||
"html5tagger>=2.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"kanta>=0.9.0",
|
||||
"kanta>=0.9.2",
|
||||
"markdown-it-py>=4.2.0",
|
||||
"maxminddb>=3.1.1",
|
||||
"mdit-py-plugins>=0.6.1",
|
||||
"mediapreview[standard]>=0.2.3",
|
||||
"mediapreview[standard]>=0.2.5",
|
||||
"platformdirs>=4.11.5",
|
||||
"pygments>=2.20.0",
|
||||
"python-slugify>=8.0.4",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import sys
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
@@ -10,20 +11,27 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
@@ -32,7 +40,7 @@ def _check_node_version(node_path: str) -> None:
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
result = subprocess.run( # noqa: S603
|
||||
[node_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -220,7 +228,7 @@ def build(folder: str = "frontend") -> None:
|
||||
def run(cmd: list[str]) -> None:
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,9 +12,8 @@ from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
@@ -67,7 +67,7 @@ class ProcessGroup(asyncio.TaskGroup):
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any:
|
||||
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
@@ -84,7 +84,7 @@ class ProcessGroup(asyncio.TaskGroup):
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None:
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
"""GET url with plain asyncio streams, return the response Server header.
|
||||
|
||||
Returns an empty string when the server responds without a Server header,
|
||||
@@ -139,7 +139,7 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
|
||||
Reference in New Issue
Block a user