@dmthepm/commune 0.2.0 → 0.4.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/README.md +141 -2
- package/lib/cli/check.js +1 -1
- package/lib/cli/errors.d.ts +3 -2
- package/lib/cli/errors.js +2 -1
- package/lib/cli/gate.js +1 -1
- package/lib/cli/main.js +75 -3
- package/lib/cli/output.d.ts +20 -0
- package/lib/cli/output.js +32 -0
- package/lib/cli/query.d.ts +26 -1
- package/lib/cli/query.js +75 -5
- package/lib/cli/related.d.ts +4 -1
- package/lib/cli/related.js +75 -76
- package/lib/cli/render.d.ts +18 -16
- package/lib/cli/render.js +67 -25
- package/lib/cli/site.d.ts +30 -0
- package/lib/cli/site.js +57 -0
- package/lib/cli/source.d.ts +34 -0
- package/lib/cli/source.js +74 -0
- package/lib/cli/update.d.ts +16 -0
- package/lib/cli/update.js +91 -0
- package/lib/cli/usage.d.ts +1 -1
- package/lib/cli/usage.js +36 -5
- package/lib/integration.js +29 -4
- package/lib/lib/dates.d.ts +100 -0
- package/lib/lib/dates.js +182 -0
- package/lib/lib/graph.d.ts +87 -5
- package/lib/lib/graph.js +170 -16
- package/package.json +1 -1
- package/src/components/Updates.astro +114 -0
package/lib/lib/graph.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* rules, and three subtly different opinions about trailing slashes.
|
|
9
9
|
*
|
|
10
10
|
* The rules this module owns:
|
|
11
|
-
* - which collections participate (notes, research, pages)
|
|
12
|
-
* - visibility (notes opt in with `visibility: public`; research and
|
|
13
|
-
* are always public)
|
|
11
|
+
* - which collections participate (notes, research, pages, updates)
|
|
12
|
+
* - visibility (notes opt in with `visibility: public`; research, pages and
|
|
13
|
+
* updates are always public)
|
|
14
14
|
* - canonical URLs, always with a trailing slash, matching Astro's
|
|
15
15
|
* directory build format
|
|
16
16
|
* - the title/alias lookup used to resolve `[[WikiLinks]]`
|
|
@@ -21,14 +21,23 @@ import { slug as githubSlug } from 'github-slugger';
|
|
|
21
21
|
import matter from 'gray-matter';
|
|
22
22
|
import { readFile } from 'node:fs/promises';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
+
import { readContentHistory, readMtimeDate, } from "./dates.js";
|
|
25
|
+
export { SHALLOW_WARNING, toIsoDay } from "./dates.js";
|
|
24
26
|
/** Where each collection's markdown lives, relative to the project root. */
|
|
25
27
|
export const CONTENT_DIRS = {
|
|
26
28
|
notes: 'src/content/notes',
|
|
27
29
|
research: 'src/content/research',
|
|
28
30
|
pages: 'src/content/pages',
|
|
31
|
+
updates: 'src/content/updates',
|
|
29
32
|
};
|
|
30
|
-
/**
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Collection scan order. Stable so derived artifacts are deterministic.
|
|
35
|
+
*
|
|
36
|
+
* `updates` is last because it was added last, and the order is the order
|
|
37
|
+
* `backlinks.json` is keyed in: putting it anywhere else would rewrite the
|
|
38
|
+
* whole committed artifact to say the same thing.
|
|
39
|
+
*/
|
|
40
|
+
export const COLLECTIONS = ['notes', 'research', 'pages', 'updates'];
|
|
32
41
|
/**
|
|
33
42
|
* Convert a content file path to its collection-relative slug and canonical URL.
|
|
34
43
|
*
|
|
@@ -117,7 +126,64 @@ export function normalizeDate(value) {
|
|
|
117
126
|
return value.split('T')[0];
|
|
118
127
|
return undefined;
|
|
119
128
|
}
|
|
120
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The date an author wrote down, if they wrote one down.
|
|
131
|
+
*
|
|
132
|
+
* `updated` first, then `date` — the key the changelog-shaped collections use,
|
|
133
|
+
* where the entry *is* a dated thing rather than a page that happens to have
|
|
134
|
+
* been edited. Kept as one function so the graph, `--recent` and the site-wide
|
|
135
|
+
* last-updated value can never disagree about what an author claimed.
|
|
136
|
+
*/
|
|
137
|
+
export function claimedDate(data) {
|
|
138
|
+
return normalizeDate(data.updated) ?? normalizeDate(data.date);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Decide an entry's dates, and say where the answer came from.
|
|
142
|
+
*
|
|
143
|
+
* Precedence, for both `created` and `updated`: what the author wrote, then
|
|
144
|
+
* what the repository records, then — only outside a repository — the file's
|
|
145
|
+
* mtime. The first is a claim and can be years stale; the second is a fact
|
|
146
|
+
* about the file and is what the ticket asked for; the third exists so a
|
|
147
|
+
* developer's unversioned folder produces dates instead of blanks.
|
|
148
|
+
*
|
|
149
|
+
* What is deliberately *not* here is a fourth fallback. Inside a shallow
|
|
150
|
+
* checkout there is no honest date to be had — every file's only commit is the
|
|
151
|
+
* one the CI runner fetched, and every file's mtime is the moment it was
|
|
152
|
+
* written to disk — so both are refused and `updatedSource` says `none`. A
|
|
153
|
+
* missing date a site can decline to render; a wrong one it renders
|
|
154
|
+
* confidently. The same goes for a file that has never been committed.
|
|
155
|
+
*
|
|
156
|
+
* `updatedSource` is reported and `createdSource` is not, deliberately: the
|
|
157
|
+
* date a site displays and a reader judges is `updated`, and one honest label
|
|
158
|
+
* beside it is worth more than two labels nobody reads. `created` follows the
|
|
159
|
+
* same precedence, and `modifiedInGit` is carried whenever git knew the file,
|
|
160
|
+
* so anything that needs to audit the pair has both halves.
|
|
161
|
+
*/
|
|
162
|
+
async function resolveDates(root, file, data, history) {
|
|
163
|
+
const claimed = claimedDate(data);
|
|
164
|
+
const createdClaim = normalizeDate(data.created);
|
|
165
|
+
const committed = history.files.get(file);
|
|
166
|
+
// Read once, and only where an mtime is an answer at all: a versioned tree
|
|
167
|
+
// never stats a file, and a complete vault outside one never stats twice.
|
|
168
|
+
let mtime;
|
|
169
|
+
const fileMtime = async () => history.kind === 'unversioned' ? (mtime ??= await readMtimeDate(root, file)) : undefined;
|
|
170
|
+
const updated = claimed ?? committed?.updated ?? (await fileMtime());
|
|
171
|
+
const created = createdClaim ?? committed?.created ?? (await fileMtime());
|
|
172
|
+
const updatedSource = claimed
|
|
173
|
+
? 'frontmatter'
|
|
174
|
+
: committed
|
|
175
|
+
? 'git'
|
|
176
|
+
: updated
|
|
177
|
+
? 'mtime'
|
|
178
|
+
: 'none';
|
|
179
|
+
return {
|
|
180
|
+
...(updated ? { updated } : {}),
|
|
181
|
+
updatedSource,
|
|
182
|
+
...(created ? { created } : {}),
|
|
183
|
+
...(committed ? { modifiedInGit: committed.updated } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Is this entry publicly visible? Notes opt in; every other collection is public. */
|
|
121
187
|
function isPublic(collection, data) {
|
|
122
188
|
if (collection !== 'notes')
|
|
123
189
|
return true;
|
|
@@ -136,6 +202,9 @@ function isPublic(collection, data) {
|
|
|
136
202
|
export async function loadContentEntries(options = {}) {
|
|
137
203
|
const root = options.root ?? process.cwd();
|
|
138
204
|
const entries = [];
|
|
205
|
+
// One walk for the whole vault, before the scan rather than inside it: the
|
|
206
|
+
// alternative is a `git log` per file, which is a child process per note.
|
|
207
|
+
const history = await readContentHistory(root, Object.values(CONTENT_DIRS));
|
|
139
208
|
for (const collection of COLLECTIONS) {
|
|
140
209
|
// `cwd` keeps globby's results root-relative, which is exactly the
|
|
141
210
|
// spelling `ContentEntry.file` promises; only the read needs the join.
|
|
@@ -148,7 +217,7 @@ export async function loadContentEntries(options = {}) {
|
|
|
148
217
|
continue;
|
|
149
218
|
const { slug, urlPath } = toUrlPath(file, collection, data);
|
|
150
219
|
const summary = typeof data.summary === 'string' ? data.summary : undefined;
|
|
151
|
-
const
|
|
220
|
+
const dates = await resolveDates(root, file, data, history);
|
|
152
221
|
entries.push({
|
|
153
222
|
slug,
|
|
154
223
|
urlPath,
|
|
@@ -158,7 +227,7 @@ export async function loadContentEntries(options = {}) {
|
|
|
158
227
|
tags: data.tags || [],
|
|
159
228
|
status: data.status || 'seed',
|
|
160
229
|
...(summary ? { summary } : {}),
|
|
161
|
-
...
|
|
230
|
+
...dates,
|
|
162
231
|
body: content,
|
|
163
232
|
frontmatter: data,
|
|
164
233
|
file,
|
|
@@ -298,6 +367,17 @@ export function stripCode(content) {
|
|
|
298
367
|
}
|
|
299
368
|
/** Frontmatter keys whose values are vocabulary, not links. */
|
|
300
369
|
const NON_LINK_KEYS = new Set(['aliases', 'tags']);
|
|
370
|
+
/**
|
|
371
|
+
* Frontmatter keys whose strings are link targets in their own right.
|
|
372
|
+
*
|
|
373
|
+
* Everywhere else in frontmatter a link has to be spelled `[[like this]]`,
|
|
374
|
+
* because a bare string is usually prose and guessing otherwise would turn a
|
|
375
|
+
* page's own `url:` into a self-edge. Under `links:` the guess is the whole
|
|
376
|
+
* point: an update declares the pages it rolls up, and writing them as
|
|
377
|
+
* `[[double brackets]]` inside a YAML list is ceremony for a field that means
|
|
378
|
+
* nothing else.
|
|
379
|
+
*/
|
|
380
|
+
const LINK_KEYS = new Set(['links']);
|
|
301
381
|
/**
|
|
302
382
|
* Extract every outbound link target from a markdown body and its frontmatter.
|
|
303
383
|
*
|
|
@@ -333,27 +413,34 @@ export function extractLinks(content, frontmatter = {}) {
|
|
|
333
413
|
*/
|
|
334
414
|
function extractFrontmatterLinks(frontmatter) {
|
|
335
415
|
const links = [];
|
|
336
|
-
const walk = (value) => {
|
|
416
|
+
const walk = (value, bare) => {
|
|
337
417
|
if (typeof value === 'string') {
|
|
338
418
|
for (const match of value.matchAll(WIKILINK)) {
|
|
339
419
|
const target = stripSubpath(match[1]);
|
|
340
420
|
if (target)
|
|
341
421
|
links.push({ kind: 'name', target });
|
|
342
422
|
}
|
|
423
|
+
// A string that already spelled its link is not also a bare target.
|
|
424
|
+
if (bare && !value.includes('[[')) {
|
|
425
|
+
const link = classifyFrontmatterTarget(value);
|
|
426
|
+
if (link)
|
|
427
|
+
links.push(link);
|
|
428
|
+
}
|
|
343
429
|
}
|
|
344
430
|
else if (Array.isArray(value)) {
|
|
345
|
-
value
|
|
431
|
+
for (const nested of value)
|
|
432
|
+
walk(nested, bare);
|
|
346
433
|
}
|
|
347
434
|
else if (value && typeof value === 'object') {
|
|
348
435
|
for (const [key, nested] of Object.entries(value)) {
|
|
349
436
|
if (!NON_LINK_KEYS.has(key))
|
|
350
|
-
walk(nested);
|
|
437
|
+
walk(nested, bare || LINK_KEYS.has(key));
|
|
351
438
|
}
|
|
352
439
|
}
|
|
353
440
|
};
|
|
354
441
|
for (const [key, value] of Object.entries(frontmatter)) {
|
|
355
442
|
if (!NON_LINK_KEYS.has(key))
|
|
356
|
-
walk(value);
|
|
443
|
+
walk(value, LINK_KEYS.has(key));
|
|
357
444
|
}
|
|
358
445
|
return links;
|
|
359
446
|
}
|
|
@@ -376,6 +463,26 @@ const MARKDOWN_LINK = /!?\[[^\]]*\]\(\s*(<[^>]*>|[^()\s]+)(?:\s+"[^"]*")?\s*\)/g
|
|
|
376
463
|
* handed to the title lookup, where it only ever resolved by the coincidence of
|
|
377
464
|
* a note listing its own slug as an alias.
|
|
378
465
|
*/
|
|
466
|
+
/**
|
|
467
|
+
* Classify one bare string from a `links:` list.
|
|
468
|
+
*
|
|
469
|
+
* A site path is a `url` link, a filename is the file's title, and anything
|
|
470
|
+
* else is read as a title — the same two namespaces every other edge resolves
|
|
471
|
+
* through, so `check` reports an unresolvable entry here exactly as it reports
|
|
472
|
+
* a broken `[[WikiLink]]`. External URLs are not edges, as everywhere else.
|
|
473
|
+
*/
|
|
474
|
+
function classifyFrontmatterTarget(raw) {
|
|
475
|
+
const value = raw.trim();
|
|
476
|
+
if (!value)
|
|
477
|
+
return null;
|
|
478
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//'))
|
|
479
|
+
return null;
|
|
480
|
+
const classified = classifyMarkdownTarget(value);
|
|
481
|
+
if (classified)
|
|
482
|
+
return classified;
|
|
483
|
+
const title = stripSubpath(value);
|
|
484
|
+
return title ? { kind: 'name', target: title } : null;
|
|
485
|
+
}
|
|
379
486
|
function classifyMarkdownTarget(raw) {
|
|
380
487
|
const destination = raw.replace(/^<|>$/g, '').trim();
|
|
381
488
|
// Anything with a scheme, and protocol-relative `//host`, leaves the site.
|
|
@@ -454,8 +561,10 @@ export const STAR_CONFIG = {
|
|
|
454
561
|
*/
|
|
455
562
|
export function calculateStars(notes) {
|
|
456
563
|
const starredSlugs = new Set();
|
|
457
|
-
// Standalone pages belong in search and WikiLinks, not
|
|
458
|
-
|
|
564
|
+
// Standalone pages and changelog updates belong in search and WikiLinks, not
|
|
565
|
+
// note rankings. An update links to everything it rolled up, so ranking it
|
|
566
|
+
// alongside notes would let the changelog crowd out the writing it describes.
|
|
567
|
+
const notesArray = Array.from(notes.values()).filter((note) => note.collection !== 'pages' && note.collection !== 'updates');
|
|
459
568
|
// Skip if too few notes
|
|
460
569
|
if (notesArray.length < STAR_CONFIG.minNotesForStars) {
|
|
461
570
|
return starredSlugs;
|
|
@@ -540,6 +649,14 @@ export function buildGraph(entries) {
|
|
|
540
649
|
for (const entry of entries) {
|
|
541
650
|
extracted.set(entry.urlPath, extractLinks(entry.body, entry.frontmatter));
|
|
542
651
|
files.set(entry.urlPath, entry.file);
|
|
652
|
+
// `updated` here is the author's claim, not the resolved date on the
|
|
653
|
+
// entry, and it stays that way on purpose. `backlinks.json` is committed,
|
|
654
|
+
// and a git-derived date cannot be committed alongside the commit that
|
|
655
|
+
// changes it: the file's new date does not exist until that commit does,
|
|
656
|
+
// so every content commit would land with the artifact already stale.
|
|
657
|
+
// The resolved date is on `ContentEntry`, in `graph query --json`, and in
|
|
658
|
+
// the build-time `site.json` — all of which are computed, not stored.
|
|
659
|
+
const claimed = claimedDate(entry.frontmatter);
|
|
543
660
|
notes.set(entry.urlPath, {
|
|
544
661
|
slug: entry.urlPath,
|
|
545
662
|
title: entry.title,
|
|
@@ -550,7 +667,7 @@ export function buildGraph(entries) {
|
|
|
550
667
|
tags: entry.tags,
|
|
551
668
|
status: entry.status,
|
|
552
669
|
...(entry.summary ? { summary: entry.summary } : {}),
|
|
553
|
-
...(
|
|
670
|
+
...(claimed ? { updated: claimed } : {}),
|
|
554
671
|
});
|
|
555
672
|
}
|
|
556
673
|
const diagnostics = [];
|
|
@@ -579,7 +696,14 @@ export function buildGraph(entries) {
|
|
|
579
696
|
}
|
|
580
697
|
}
|
|
581
698
|
if (resolved && notes.has(resolved)) {
|
|
582
|
-
|
|
699
|
+
// One edge per target, not one per spelling. `[[World]]` in the body
|
|
700
|
+
// and `/notes/world/` under `links:` are the same edge written two
|
|
701
|
+
// ways, and an update names both — the prose says it and the
|
|
702
|
+
// frontmatter lists it. `inbound` has always been deduplicated;
|
|
703
|
+
// `outbound` reaching the same page twice was the same fact
|
|
704
|
+
// counted twice.
|
|
705
|
+
if (!resolvedOutbound.includes(resolved))
|
|
706
|
+
resolvedOutbound.push(resolved);
|
|
583
707
|
const target = notes.get(resolved);
|
|
584
708
|
if (!target.inbound.includes(fromUrl)) {
|
|
585
709
|
target.inbound.push(fromUrl);
|
|
@@ -635,6 +759,36 @@ export function formatDiagnostic(diagnostic) {
|
|
|
635
759
|
}
|
|
636
760
|
return `⚠️ ${diagnostic.rule} in ${diagnostic.file}: ${diagnostic.message}`;
|
|
637
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* The newest change across the whole site, and which entry it was.
|
|
764
|
+
*
|
|
765
|
+
* "When did this wiki last change" is not the same question as "when did this
|
|
766
|
+
* page last change", and a home page that answers the second while appearing
|
|
767
|
+
* to answer the first is the bug this ticket opened on. Ties go to the entry
|
|
768
|
+
* scanned first, which is stable because the scan is.
|
|
769
|
+
*/
|
|
770
|
+
export function summarizeSite(entries) {
|
|
771
|
+
let newest;
|
|
772
|
+
let newestInGit;
|
|
773
|
+
for (const entry of entries) {
|
|
774
|
+
if (entry.updated && (!newest || entry.updated > newest.updated))
|
|
775
|
+
newest = entry;
|
|
776
|
+
if (entry.modifiedInGit && (!newestInGit || entry.modifiedInGit > newestInGit)) {
|
|
777
|
+
newestInGit = entry.modifiedInGit;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
...(newest
|
|
782
|
+
? {
|
|
783
|
+
lastUpdated: newest.updated,
|
|
784
|
+
lastUpdatedPath: newest.urlPath,
|
|
785
|
+
lastUpdatedSource: newest.updatedSource,
|
|
786
|
+
}
|
|
787
|
+
: {}),
|
|
788
|
+
...(newestInGit ? { lastModifiedInGit: newestInGit } : {}),
|
|
789
|
+
entries: entries.length,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
638
792
|
/**
|
|
639
793
|
* The public artifact, exactly as `public/backlinks.json` stores it.
|
|
640
794
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmthepm/commune",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "An Astro wiki engine with WikiLinks, sliding panes, backlinks and static search, plus a commune CLI that queries the content graph and checks links",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The newest entries from the `updates` collection, as a card.
|
|
4
|
+
*
|
|
5
|
+
* A wiki's front door has to answer "what changed" before it answers anything
|
|
6
|
+
* else, and the answer is content the site already has: one dated entry per
|
|
7
|
+
* batch of work, in `src/content/updates/`. This renders the newest few.
|
|
8
|
+
*
|
|
9
|
+
* Everything happens at build time. There is no `fetch` here and no client
|
|
10
|
+
* script — unlike `Backlinks.astro`, which reads `/backlinks.json` in the
|
|
11
|
+
* browser because a note's inbound links depend on which note you are on. A
|
|
12
|
+
* changelog does not, so it is markup by the time the page is served, and it
|
|
13
|
+
* is in the HTML for a reader with no JavaScript and for anything that reads
|
|
14
|
+
* the page as a document.
|
|
15
|
+
*
|
|
16
|
+
* The collection has to be registered in the consumer's `src/content.config.ts`
|
|
17
|
+
* for `getCollection` to see it — the same requirement `notes` has. The schema
|
|
18
|
+
* this expects is in the README.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getCollection } from 'astro:content';
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
/** How many entries to show. */
|
|
25
|
+
limit?: number;
|
|
26
|
+
/** The card's heading. Passed rather than fixed, because a card that says
|
|
27
|
+
* "Recent updates" on the home page often wants to say nothing at all on
|
|
28
|
+
* the updates index itself. An empty string renders no heading. */
|
|
29
|
+
heading?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { limit = 5, heading = 'Recent updates' } = Astro.props;
|
|
33
|
+
|
|
34
|
+
// `date` is the update's subject — the day the work happened — not a fact
|
|
35
|
+
// about the file, so entries sort by it and not by their history. Descending,
|
|
36
|
+
// and by id as the tiebreak so two updates dated the same day have a stable
|
|
37
|
+
// order rather than the filesystem's.
|
|
38
|
+
const updates = (await getCollection('updates'))
|
|
39
|
+
.sort((a, b) => b.data.date.localeCompare(a.data.date) || b.id.localeCompare(a.id))
|
|
40
|
+
.slice(0, limit);
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<section class="commune-updates">
|
|
44
|
+
{heading && <h2 class="commune-updates-title">{heading}</h2>}
|
|
45
|
+
|
|
46
|
+
{updates.length === 0 ? (
|
|
47
|
+
<p class="commune-updates-empty">No updates yet.</p>
|
|
48
|
+
) : (
|
|
49
|
+
<ul class="commune-updates-list">
|
|
50
|
+
{updates.map((update) => (
|
|
51
|
+
<li class="commune-updates-item">
|
|
52
|
+
<a href={`/updates/${update.id}/`} class="commune-updates-link">
|
|
53
|
+
{update.data.title}
|
|
54
|
+
</a>
|
|
55
|
+
<time class="commune-updates-date" datetime={update.data.date}>
|
|
56
|
+
{update.data.date}
|
|
57
|
+
</time>
|
|
58
|
+
{update.data.summary && (
|
|
59
|
+
<p class="commune-updates-summary">{update.data.summary}</p>
|
|
60
|
+
)}
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
.commune-updates-title {
|
|
69
|
+
font-size: 1.1rem;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
margin: 0 0 1rem 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.commune-updates-list {
|
|
75
|
+
list-style: none;
|
|
76
|
+
padding: 0;
|
|
77
|
+
margin: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.commune-updates-item {
|
|
81
|
+
margin-bottom: 1rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.commune-updates-item:last-child {
|
|
85
|
+
margin-bottom: 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.commune-updates-link {
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
text-decoration: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.commune-updates-link:hover {
|
|
94
|
+
text-decoration: underline;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.commune-updates-date {
|
|
98
|
+
display: block;
|
|
99
|
+
font-size: 0.8rem;
|
|
100
|
+
opacity: 0.7;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.commune-updates-summary {
|
|
104
|
+
margin: 0.25rem 0 0 0;
|
|
105
|
+
font-size: 0.9rem;
|
|
106
|
+
line-height: 1.5;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.commune-updates-empty {
|
|
110
|
+
margin: 0;
|
|
111
|
+
font-style: italic;
|
|
112
|
+
opacity: 0.7;
|
|
113
|
+
}
|
|
114
|
+
</style>
|