@vit-foundation/ui 0.22.0 → 0.24.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/dist/admin.d.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  * components receive already-filtered data through props, the Nav/Footer
6
6
  * genericization pattern.
7
7
  */
8
- export { default as AdminShell } from './components/admin/AdminShell.svelte';
9
8
  export { default as DecorMosaic } from './components/admin/DecorMosaic.svelte';
10
9
  export { default as PageHeading } from './components/admin/PageHeading.svelte';
11
10
  export { default as Sidebar, type SidebarItem } from './components/admin/Sidebar.svelte';
package/dist/admin.js CHANGED
@@ -5,7 +5,15 @@
5
5
  * components receive already-filtered data through props, the Nav/Footer
6
6
  * genericization pattern.
7
7
  */
8
- export { default as AdminShell } from './components/admin/AdminShell.svelte';
8
+ /*
9
+ * `AdminShell` was exported here — a public name behind two doors with zero
10
+ * readers in either host, no test, and one story. Seven props, five of them
11
+ * forwarded verbatim to `Sidebar`, and the only implementation it added beyond
12
+ * the forwarding was `main { margin-left: 4.5rem }`. The one admin host went
13
+ * the other way: vit-brain imports `Sidebar` directly and composes its own
14
+ * shell with a different offset, so the rail's width had two answers and the
15
+ * untested one was nobody's. Deleting it left nothing to reappear.
16
+ */
9
17
  export { default as DecorMosaic } from './components/admin/DecorMosaic.svelte';
10
18
  export { default as PageHeading } from './components/admin/PageHeading.svelte';
11
19
  export { default as Sidebar } from './components/admin/Sidebar.svelte';
@@ -95,7 +95,15 @@
95
95
  box-shadow: var(--shadow-1);
96
96
  }
97
97
 
98
- .item {
98
+ /*
99
+ * The rail's footprint, and the host's footer control with it — "should sit
100
+ * like an item" was the comment on a second, identical copy of these eleven
101
+ * declarations, and vit-brain had a third on its own logout button. One
102
+ * selector list is the whole rule: a host's footer submit is an item that
103
+ * happens to come from outside.
104
+ */
105
+ .item,
106
+ .footer :global(button) {
99
107
  display: inline-flex;
100
108
  align-items: center;
101
109
  justify-content: center;
@@ -109,7 +117,8 @@
109
117
  transition: background var(--transition-fast);
110
118
  }
111
119
 
112
- .item:hover {
120
+ .item:hover,
121
+ .footer :global(button:hover) {
113
122
  background: color-mix(in srgb, var(--color-navy) 10%, transparent);
114
123
  }
115
124
 
@@ -122,25 +131,6 @@
122
131
  margin-top: auto;
123
132
  }
124
133
 
125
- /* The host's footer control (a logout submit) should sit like an item. */
126
- .footer :global(button) {
127
- display: inline-flex;
128
- align-items: center;
129
- justify-content: center;
130
- width: 2.4rem;
131
- height: 2.4rem;
132
- border-radius: 999px;
133
- color: var(--color-navy);
134
- border: none;
135
- background: transparent;
136
- cursor: pointer;
137
- transition: background var(--transition-fast);
138
- }
139
-
140
- .footer :global(button:hover) {
141
- background: color-mix(in srgb, var(--color-navy) 10%, transparent);
142
- }
143
-
144
134
  @media print {
145
135
  .sidebar {
146
136
  display: none;
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { getUiConfig } from '../../config/context.js';
4
+ import { documentTitle } from '../../utils/document-title.js';
4
5
 
5
6
  /**
6
7
  * The chrome every page shares: the content column, the vertical rhythm,
7
- * and the browser title, composed as `title — siteName` from the config's
8
- * site name. (The foundation site's error page spells the same format on
9
- * its own; the two are one decision, noted in both places.)
8
+ * and the browser title. The title FORMAT is `documentTitle`'s — the
9
+ * foundation site's error page cannot render through this component and
10
+ * needs the same format, and the two used to spell it separately.
10
11
  *
11
12
  * The shape is one named variant rather than four presentation flags.
12
13
  * Width, spacing, stacking and the wrapper element were independent props,
@@ -45,7 +46,7 @@
45
46
  let { title, description, variant = 'content', children }: Props = $props();
46
47
 
47
48
  const config = getUiConfig();
48
- const pageTitle = $derived(title ? `${title} — ${config.siteName}` : config.siteName);
49
+ const pageTitle = $derived(documentTitle(title, config.siteName));
49
50
  /**
50
51
  * The editorial variants render an <article>, and say so to crawlers too.
51
52
  * og:type was hardcoded to 'website' while the element already switched,
@@ -1,9 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  /**
3
3
  * The chrome every page shares: the content column, the vertical rhythm,
4
- * and the browser title, composed as `title — siteName` from the config's
5
- * site name. (The foundation site's error page spells the same format on
6
- * its own; the two are one decision, noted in both places.)
4
+ * and the browser title. The title FORMAT is `documentTitle`'s — the
5
+ * foundation site's error page cannot render through this component and
6
+ * needs the same format, and the two used to spell it separately.
7
7
  *
8
8
  * The shape is one named variant rather than four presentation flags.
9
9
  * Width, spacing, stacking and the wrapper element were independent props,
@@ -1,4 +1,4 @@
1
1
  export { renderBody } from './richtext.js';
2
2
  export type { RichTextBlock } from './richtext.js';
3
- export { CONTACT_CATEGORIES, MILESTONE_CATEGORIES, PROJECT_KINDS, REACTIONS } from './types.js';
4
- export type { CollaboratorData, CommentData, CommentThreadData, ContactCategory, FieldConstraint, FormFailReason, JobOpeningData, MilestoneCategory, MilestoneData, ProjectCardData, ProjectKind, Reaction, ReactionSummary, ReactionTarget, SortDirection, TeamMemberData, WeeklyCardData } from './types.js';
3
+ export { COMMENT_STATUSES, CONTACT_CATEGORIES, MILESTONE_CATEGORIES, PROJECT_KINDS, REACTIONS } from './types.js';
4
+ export type { CollaboratorData, CommentData, CommentThreadData, CommentStatus, ContactCategory, FieldConstraint, FormFailReason, JobOpeningData, MilestoneCategory, MilestoneData, ProjectCardData, ProjectKind, Reaction, ReactionSummary, ReactionTarget, SortDirection, TeamMemberData, WeeklyCardData } from './types.js';
@@ -1,2 +1,2 @@
1
1
  export { renderBody } from './richtext.js';
2
- export { CONTACT_CATEGORIES, MILESTONE_CATEGORIES, PROJECT_KINDS, REACTIONS } from './types.js';
2
+ export { COMMENT_STATUSES, CONTACT_CATEGORIES, MILESTONE_CATEGORIES, PROJECT_KINDS, REACTIONS } from './types.js';
@@ -126,6 +126,22 @@ export interface CommentData {
126
126
  createdAt: string;
127
127
  reactions: ReactionSummary[];
128
128
  }
129
+ /**
130
+ * A comment's moderation state, as the database constrains it.
131
+ *
132
+ * The set is `comments.status`'s CHECK in fndvit-website and the vocabulary
133
+ * vit-brain's Comentaris tab moderates through — two repositories that cannot
134
+ * import each other — and it had no owner in TypeScript at all: the site read
135
+ * `status = 'published'` as a bare SQL literal in four queries, typed the
136
+ * field `z.string()`, and never named the other two states anywhere. A test
137
+ * fixture using `'visible'`, a value the CHECK refuses, passed green.
138
+ *
139
+ * `published` is what a reader sees; `hidden` is moderated away; `pending`
140
+ * awaits review. Which of them a given surface SHOWS is that surface's rule,
141
+ * not this list's.
142
+ */
143
+ export declare const COMMENT_STATUSES: readonly ["published", "pending", "hidden"];
144
+ export type CommentStatus = (typeof COMMENT_STATUSES)[number];
129
145
  /** A top-level comment with its flat reply list. */
130
146
  export interface CommentThreadData extends CommentData {
131
147
  replies: CommentData[];
@@ -29,5 +29,20 @@ export const MILESTONE_CATEGORIES = [
29
29
  ];
30
30
  /** The reactions a weekly or comment can carry, in display order. */
31
31
  export const REACTIONS = ['like', 'love', 'clap'];
32
+ /**
33
+ * A comment's moderation state, as the database constrains it.
34
+ *
35
+ * The set is `comments.status`'s CHECK in fndvit-website and the vocabulary
36
+ * vit-brain's Comentaris tab moderates through — two repositories that cannot
37
+ * import each other — and it had no owner in TypeScript at all: the site read
38
+ * `status = 'published'` as a bare SQL literal in four queries, typed the
39
+ * field `z.string()`, and never named the other two states anywhere. A test
40
+ * fixture using `'visible'`, a value the CHECK refuses, passed green.
41
+ *
42
+ * `published` is what a reader sees; `hidden` is moderated away; `pending`
43
+ * awaits review. Which of them a given surface SHOWS is that surface's rule,
44
+ * not this list's.
45
+ */
46
+ export const COMMENT_STATUSES = ['published', 'pending', 'hidden'];
32
47
  /** The contact form's reasons, in display order — the host schema derives its enum from this. */
33
48
  export const CONTACT_CATEGORIES = ['collaborate', 'event', 'press', 'brand', 'other'];
@@ -23,7 +23,7 @@ export { createUrlFilters } from './utils/url-filters.svelte.js';
23
23
  export type { UrlFilters, UrlFiltersConfig } from './utils/url-filters.svelte.js';
24
24
  export { createWeeklyList } from './utils/weekly-list.svelte.js';
25
25
  export type { WeeklyList, WeeklyListConfig } from './utils/weekly-list.svelte.js';
26
- export { WEEKLY_LIST_DEFAULTS } from './utils/weekly-list-contract.js';
26
+ export { WEEKLY_LIST_DEFAULTS, WEEKLY_LIST_PARAMS, parseWeeklyListUrl } from './utils/weekly-list-contract.js';
27
27
  export type { WeeklyListFilters, WeeklyListPage, WeeklyListServerData } from './utils/weekly-list-contract.js';
28
28
  export { contactCategoryLabel } from './utils/contact.js';
29
29
  export { formatDate, yearOf } from './utils/dates.js';
@@ -18,6 +18,6 @@ export { createUrlFilters } from './utils/url-filters.svelte.js';
18
18
  export { createWeeklyList } from './utils/weekly-list.svelte.js';
19
19
  // The list's contract is component-free on purpose, so ./contract carries it
20
20
  // too — a host's +page.server.ts reads these without loading the grid.
21
- export { WEEKLY_LIST_DEFAULTS } from './utils/weekly-list-contract.js';
21
+ export { WEEKLY_LIST_DEFAULTS, WEEKLY_LIST_PARAMS, parseWeeklyListUrl } from './utils/weekly-list-contract.js';
22
22
  export { contactCategoryLabel } from './utils/contact.js';
23
23
  export { formatDate, yearOf } from './utils/dates.js';
@@ -45,14 +45,20 @@ export { REACTIONS } from './content/types.js';
45
45
  export type { Reaction, ReactionSummary, SortDirection } from './content/types.js';
46
46
  export { MILESTONE_CATEGORIES, PROJECT_KINDS } from './content/types.js';
47
47
  export type { MilestoneCategory, ProjectKind } from './content/types.js';
48
- export { WEEKLY_LIST_DEFAULTS } from './utils/weekly-list-contract.js';
48
+ export { WEEKLY_LIST_DEFAULTS, WEEKLY_LIST_PARAMS, parseWeeklyListUrl } from './utils/weekly-list-contract.js';
49
49
  export type { WeeklyListFilters, WeeklyListPage, WeeklyListServerData } from './utils/weekly-list-contract.js';
50
50
  export { CONTACT_CATEGORIES } from './content/types.js';
51
- export type { ContactCategory, FieldConstraint, FormFailReason } from './content/types.js';
51
+ export { COMMENT_STATUSES } from './content/types.js';
52
+ export type { CommentStatus, ContactCategory, FieldConstraint, FormFailReason } from './content/types.js';
52
53
  export { COMMENT_BODY, CONTACT_MESSAGE, CONTACT_NAME, DISPLAY_NAME, EMAIL, LOGIN_PASSWORD, PASSWORD } from './forms/constraints.js';
53
54
  export { hasNewsletterIntent, HONEYPOT_FIELD, isNewsletterIntent, NEWSLETTER_INTENT_PARAM, NEWSLETTER_INTENT_VALUE, withNewsletterIntent } from './forms/transport.js';
54
55
  export type { FormFail, FormFieldIssue, FormResultLike, FormResultOf, KeyedRemoteForms, RemoteField, RemoteFormAttributes, RemoteFormInstance } from './forms/types.js';
55
- export { buildQueryString, isExternalUrl, isInternalPath, isPathUnder } from './utils/paths.js';
56
+ export { buildQueryString, isExternalUrl, isInternalPath } from './utils/paths.js';
57
+ /**
58
+ * The browser-title format. On `./contract` because its second reader is a
59
+ * host's error page, which composes a title without rendering `PageShell`.
60
+ */
61
+ export { documentTitle } from './utils/document-title.js';
56
62
  /**
57
63
  * The descriptor factories. `edit/helpers.ts` imports nothing but types from
58
64
  * two modules this subpath already anchors, so it needed a door rather than a
package/dist/contract.js CHANGED
@@ -44,11 +44,24 @@ export { REACTIONS } from './content/types.js';
44
44
  // contact form already derives from CONTACT_CATEGORIES below. Values, because
45
45
  // a type cannot be the source of an enum.
46
46
  export { MILESTONE_CATEGORIES, PROJECT_KINDS } from './content/types.js';
47
- export { WEEKLY_LIST_DEFAULTS } from './utils/weekly-list-contract.js';
47
+ // The weeklies URL contract, both halves: the values a param stands for when
48
+ // absent, the names it travels as, and the parse that reads one back. The read
49
+ // half had three spellings across two repositories and this package's own test,
50
+ // and the round-trip case guarding them crossed against the test's copy.
51
+ export { WEEKLY_LIST_DEFAULTS, WEEKLY_LIST_PARAMS, parseWeeklyListUrl } from './utils/weekly-list-contract.js';
48
52
  export { CONTACT_CATEGORIES } from './content/types.js';
53
+ export { COMMENT_STATUSES } from './content/types.js';
49
54
  export { COMMENT_BODY, CONTACT_MESSAGE, CONTACT_NAME, DISPLAY_NAME, EMAIL, LOGIN_PASSWORD, PASSWORD } from './forms/constraints.js';
50
55
  export { hasNewsletterIntent, HONEYPOT_FIELD, isNewsletterIntent, NEWSLETTER_INTENT_PARAM, NEWSLETTER_INTENT_VALUE, withNewsletterIntent } from './forms/transport.js';
51
- export { buildQueryString, isExternalUrl, isInternalPath, isPathUnder } from './utils/paths.js';
56
+ // `isPathUnder` is deliberately absent: `Nav` and `Sidebar` reach it by relative
57
+ // import and no host has ever imported it, so exporting it pinned a prefix rule
58
+ // into semver for nobody. See utils/paths.ts.
59
+ export { buildQueryString, isExternalUrl, isInternalPath } from './utils/paths.js';
60
+ /**
61
+ * The browser-title format. On `./contract` because its second reader is a
62
+ * host's error page, which composes a title without rendering `PageShell`.
63
+ */
64
+ export { documentTitle } from './utils/document-title.js';
52
65
  /**
53
66
  * The descriptor factories. `edit/helpers.ts` imports nothing but types from
54
67
  * two modules this subpath already anchors, so it needed a door rather than a
package/dist/index.js CHANGED
@@ -12,13 +12,7 @@ export * from './forms/index.js';
12
12
  // App wiring: configuration context and the edit-mode contract.
13
13
  export * from './config/index.js';
14
14
  export * from './edit/index.js';
15
- // The one path utility a host actually reads: fndvit-website re-exports
16
- // `buildQueryString` from its own `utils/nav.ts`, so the URL a list mirrors
17
- // and the hrefs it renders are spelled by this function in both codebases.
18
- // The other three are NOT exported. `isInternalPath` and `isExternalUrl` are
19
- // the destination classifiers `Link` and `TimelineMilestone` branch on, and
20
- // `isPathUnder` is the prefix match `Nav` and `Sidebar` highlight the current
21
- // section with — all four in-package readers reach them by deep import, and
22
- // no host imports any of the three. Exporting them would pin their behaviour
23
- // into semver for no reader; `paths.test.ts` is what holds them instead.
15
+ // The one path utility this BARREL carries. The destination classifiers are
16
+ // server-side rules, so they go through ./contract instead — see the note in
17
+ // utils/paths.ts, which is where the export surface for all of them is decided.
24
18
  export { buildQueryString } from './utils/paths.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What a source scan needs to see, and the anchors that keep it honest.
3
+ *
4
+ * Two guards in this package walk the source: `contract.test.ts` follows the
5
+ * relative import graph beneath `./contract`, and `no-app-imports.test.ts`
6
+ * scans every module for an app virtual specifier. They ask different
7
+ * questions of the same rule — WHICH MODULES DOES THIS ONE NAME — and each
8
+ * used to answer it with a pattern of its own. `contract.test.ts` found the
9
+ * defect in its copy and fixed it there:
10
+ *
11
+ * "THREE import forms, because two of them used to be invisible here. The
12
+ * pattern required the `from` keyword, so a side-effect import and a
13
+ * dynamic one both walked past a guard whose whole job is to see them."
14
+ *
15
+ * The other copy was not fixed and still required `from`, so a dynamic import
16
+ * of an $app specifier walked past the gate that decides whether this package
17
+ * is publishable. Copying the lesson a third time is how a guard
18
+ * starts missing things again; the grammar lives here instead, and each guard
19
+ * filters the specifiers it cares about.
20
+ */
21
+ /**
22
+ * Every module specifier a source file names, in all three forms a bundler
23
+ * follows: `from '…'` (import and re-export alike), a bare side-effect
24
+ * `import '…'`, and a dynamic `import('…')`.
25
+ *
26
+ * A type-only import is reported like any other. That is deliberate, and it
27
+ * is `contract.test.ts`'s standing argument: erasure is a build-time fact, and
28
+ * a pattern that has to understand `import type` is a pattern with a second
29
+ * way to be wrong.
30
+ */
31
+ export declare function importSpecifiers(source: string): readonly string[];
32
+ /**
33
+ * Every `.ts` and `.svelte` file beneath `root`, and the ones the walk MUST
34
+ * find.
35
+ *
36
+ * `mustReach` is a required argument for the reason `contract.test.ts` gives
37
+ * at length: an assertion over an empty list passes. `expect(offenders)
38
+ * .toEqual([])` is a clean bill of health over a walk that enumerated nothing
39
+ * — a directory renamed, a `readdirSync` that threw and was caught upstream,
40
+ * an entry filter that stopped matching — and a safety net that can narrow in
41
+ * silence reproduces the defect it exists to prevent. fndvit-website's
42
+ * `src/lib/testing/source-scan.ts` settled the shape: a scan cannot walk the
43
+ * tree without saying what it must find.
44
+ *
45
+ * Anchors are relative to `root` with `/` separators, so one representation
46
+ * crosses this interface and an anchor cannot silently fail to match an
47
+ * absolute path. A FLOOR was the alternative and is the thing being replaced:
48
+ * a walk that quietly stopped seeing two thirds of the tree clears any floor
49
+ * worth setting.
50
+ */
51
+ export declare function sourceFiles(root: string, mustReach: readonly string[]): readonly string[];
@@ -0,0 +1,70 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ /**
4
+ * What a source scan needs to see, and the anchors that keep it honest.
5
+ *
6
+ * Two guards in this package walk the source: `contract.test.ts` follows the
7
+ * relative import graph beneath `./contract`, and `no-app-imports.test.ts`
8
+ * scans every module for an app virtual specifier. They ask different
9
+ * questions of the same rule — WHICH MODULES DOES THIS ONE NAME — and each
10
+ * used to answer it with a pattern of its own. `contract.test.ts` found the
11
+ * defect in its copy and fixed it there:
12
+ *
13
+ * "THREE import forms, because two of them used to be invisible here. The
14
+ * pattern required the `from` keyword, so a side-effect import and a
15
+ * dynamic one both walked past a guard whose whole job is to see them."
16
+ *
17
+ * The other copy was not fixed and still required `from`, so a dynamic import
18
+ * of an $app specifier walked past the gate that decides whether this package
19
+ * is publishable. Copying the lesson a third time is how a guard
20
+ * starts missing things again; the grammar lives here instead, and each guard
21
+ * filters the specifiers it cares about.
22
+ */
23
+ /**
24
+ * Every module specifier a source file names, in all three forms a bundler
25
+ * follows: `from '…'` (import and re-export alike), a bare side-effect
26
+ * `import '…'`, and a dynamic `import('…')`.
27
+ *
28
+ * A type-only import is reported like any other. That is deliberate, and it
29
+ * is `contract.test.ts`'s standing argument: erasure is a build-time fact, and
30
+ * a pattern that has to understand `import type` is a pattern with a second
31
+ * way to be wrong.
32
+ */
33
+ export function importSpecifiers(source) {
34
+ return [...source.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*['"]([^'"]*)['"]/g)].map(([, specifier]) => specifier);
35
+ }
36
+ /**
37
+ * Every `.ts` and `.svelte` file beneath `root`, and the ones the walk MUST
38
+ * find.
39
+ *
40
+ * `mustReach` is a required argument for the reason `contract.test.ts` gives
41
+ * at length: an assertion over an empty list passes. `expect(offenders)
42
+ * .toEqual([])` is a clean bill of health over a walk that enumerated nothing
43
+ * — a directory renamed, a `readdirSync` that threw and was caught upstream,
44
+ * an entry filter that stopped matching — and a safety net that can narrow in
45
+ * silence reproduces the defect it exists to prevent. fndvit-website's
46
+ * `src/lib/testing/source-scan.ts` settled the shape: a scan cannot walk the
47
+ * tree without saying what it must find.
48
+ *
49
+ * Anchors are relative to `root` with `/` separators, so one representation
50
+ * crosses this interface and an anchor cannot silently fail to match an
51
+ * absolute path. A FLOOR was the alternative and is the thing being replaced:
52
+ * a walk that quietly stopped seeing two thirds of the tree clears any floor
53
+ * worth setting.
54
+ */
55
+ export function sourceFiles(root, mustReach) {
56
+ const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
57
+ const path = join(dir, entry.name);
58
+ if (entry.isDirectory())
59
+ return walk(path);
60
+ return /\.(ts|svelte)$/.test(entry.name) ? [path] : [];
61
+ });
62
+ const files = walk(root);
63
+ const found = new Set(files.map((file) => relative(root, file).replaceAll('\\', '/')));
64
+ const missing = mustReach.filter((anchor) => !found.has(anchor));
65
+ if (missing.length > 0) {
66
+ throw new Error(`the source scan enumerated ${files.length} files but not ${missing.join(', ')} — ` +
67
+ 'either the module moved or the walk stopped seeing it');
68
+ }
69
+ return files;
70
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How a page names itself to the browser: `title — siteName`, or the site name
3
+ * alone.
4
+ *
5
+ * The format had two owners. `PageShell` composed it from `UiConfig.siteName`,
6
+ * and fndvit-website's `$lib/site` composed it again for the one page that
7
+ * cannot render through `PageShell` — its error page, whose centred layout is
8
+ * not one of the six variants. Both spelled the same expression, both
9
+ * comments noted that the other existed, and `site.test.ts` pinned the result
10
+ * as the literal `'Transparència — ViT'`: change the separator in the package
11
+ * and the error page drifts with nothing failing.
12
+ *
13
+ * It lives on `./contract` rather than in `UiConfig` because a config field
14
+ * would need a package DEFAULT, and that default is the expression — the
15
+ * duplication would survive the move. No host wants a separator of its own;
16
+ * one is a hypothetical seam.
17
+ *
18
+ * `siteName` is a parameter rather than a read, so this stays component-free
19
+ * and a server module can call it (fndvit-website's `$lib/site` supplies
20
+ * `SITE_NAME`, `PageShell` supplies `config.siteName`).
21
+ */
22
+ export declare function documentTitle(title: string, siteName: string): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * How a page names itself to the browser: `title — siteName`, or the site name
3
+ * alone.
4
+ *
5
+ * The format had two owners. `PageShell` composed it from `UiConfig.siteName`,
6
+ * and fndvit-website's `$lib/site` composed it again for the one page that
7
+ * cannot render through `PageShell` — its error page, whose centred layout is
8
+ * not one of the six variants. Both spelled the same expression, both
9
+ * comments noted that the other existed, and `site.test.ts` pinned the result
10
+ * as the literal `'Transparència — ViT'`: change the separator in the package
11
+ * and the error page drifts with nothing failing.
12
+ *
13
+ * It lives on `./contract` rather than in `UiConfig` because a config field
14
+ * would need a package DEFAULT, and that default is the expression — the
15
+ * duplication would survive the move. No host wants a separator of its own;
16
+ * one is a hypothetical seam.
17
+ *
18
+ * `siteName` is a parameter rather than a read, so this stays component-free
19
+ * and a server module can call it (fndvit-website's `$lib/site` supplies
20
+ * `SITE_NAME`, `PageShell` supplies `config.siteName`).
21
+ */
22
+ export function documentTitle(title, siteName) {
23
+ // An empty title is a REAL state, not a guard: pages title themselves from
24
+ // page copy and `content.getPage` returns '' for a missing row, so the site
25
+ // name alone must ship rather than a dangling separator.
26
+ return title ? `${title} — ${siteName}` : siteName;
27
+ }
@@ -1,3 +1,30 @@
1
+ /**
2
+ * The four path rules, and which doors they go out of.
3
+ *
4
+ * This note used to live in `index.ts` and argue that only `buildQueryString`
5
+ * was exported, that the other three were private, and that exporting them
6
+ * "would pin their behaviour into semver for no reader". All three claims had
7
+ * stopped being true:
8
+ *
9
+ * - `contract.ts` exports all four, and has since the subpath reached both
10
+ * hosts.
11
+ * - Two of them DO have a host reader. fndvit-website's repository integration
12
+ * suite asserts the `site_links.href` check constraint against
13
+ * `isInternalPath` byte for byte, and the https columns against
14
+ * `isExternalUrl` — that binding is the whole reason those cases exist, and
15
+ * it is why these two belong on `./contract`: a CHECK constraint is a
16
+ * server-side rule, and the classifier that has to agree with it must be
17
+ * importable without loading a component.
18
+ * - fndvit-website does not re-export `buildQueryString` from its `nav.ts`
19
+ * any more; that pass-through is deleted and its one reader imports the
20
+ * package directly.
21
+ *
22
+ * What the old note got right is the principle, and `isPathUnder` is the name
23
+ * it applies to: it is the prefix match `Nav` and `Sidebar` highlight the
24
+ * current section with, both of them reach it by relative import, and no host
25
+ * has ever imported it. So it is the one of the four that stays in, held by
26
+ * `paths.test.ts` rather than by semver.
27
+ */
1
28
  /** Prefix match over a URL pathname: '/what-we-do' covers '/what-we-do/<slug>'. */
2
29
  export declare function isPathUnder(pathname: string, prefix: string): boolean;
3
30
  /**
@@ -1,3 +1,30 @@
1
+ /**
2
+ * The four path rules, and which doors they go out of.
3
+ *
4
+ * This note used to live in `index.ts` and argue that only `buildQueryString`
5
+ * was exported, that the other three were private, and that exporting them
6
+ * "would pin their behaviour into semver for no reader". All three claims had
7
+ * stopped being true:
8
+ *
9
+ * - `contract.ts` exports all four, and has since the subpath reached both
10
+ * hosts.
11
+ * - Two of them DO have a host reader. fndvit-website's repository integration
12
+ * suite asserts the `site_links.href` check constraint against
13
+ * `isInternalPath` byte for byte, and the https columns against
14
+ * `isExternalUrl` — that binding is the whole reason those cases exist, and
15
+ * it is why these two belong on `./contract`: a CHECK constraint is a
16
+ * server-side rule, and the classifier that has to agree with it must be
17
+ * importable without loading a component.
18
+ * - fndvit-website does not re-export `buildQueryString` from its `nav.ts`
19
+ * any more; that pass-through is deleted and its one reader imports the
20
+ * package directly.
21
+ *
22
+ * What the old note got right is the principle, and `isPathUnder` is the name
23
+ * it applies to: it is the prefix match `Nav` and `Sidebar` highlight the
24
+ * current section with, both of them reach it by relative import, and no host
25
+ * has ever imported it. So it is the one of the four that stays in, held by
26
+ * `paths.test.ts` rather than by semver.
27
+ */
1
28
  /** Prefix match over a URL pathname: '/what-we-do' covers '/what-we-do/<slug>'. */
2
29
  export function isPathUnder(pathname, prefix) {
3
30
  return pathname === prefix || pathname.startsWith(`${prefix}/`);
@@ -64,3 +64,38 @@ export interface WeeklyListServerData {
64
64
  pageSize: number;
65
65
  query: WeeklyListFilters;
66
66
  }
67
+ /**
68
+ * The four params this list travels as, in the order a URL reads.
69
+ *
70
+ * Exported so a host's query schema can be bound to them rather than agreeing
71
+ * by coincidence: fndvit-website's `weeklyListQuerySchema` names all four as
72
+ * zod fields, and nothing said they were these four.
73
+ */
74
+ export declare const WEEKLY_LIST_PARAMS: readonly ["q", "theme", "sort", "page"];
75
+ /**
76
+ * The READ half of the weeklies URL contract — what a URL this package wrote
77
+ * means when it comes back.
78
+ *
79
+ * `WEEKLY_LIST_DEFAULTS` gave the VALUES one owner and both hosts derive from
80
+ * it correctly. The param NAMES and the parse had none: they were spelled four
81
+ * times across three repositories — `toQuery`/`hrefFor` here, a `parseListUrl`
82
+ * declared inside this package's own test, `weeklyListQuerySchema` in
83
+ * fndvit-website and `weeklyQuery` in vit-brain — and the two repositories
84
+ * cannot import each other, so nothing could disagree out loud. vit-brain's
85
+ * docblock states the failure exactly: rename a param here and the read side
86
+ * drops the filter silently while the write side keeps producing it.
87
+ *
88
+ * Worse, the round-trip case that exists to catch precisely that
89
+ * ("builds URLs that parse back to the filters they were built from") crossed
90
+ * the write half against the stand-in in the test file, not against either
91
+ * real reader. It could not fail for the reason it was written.
92
+ *
93
+ * TOLERANT, because these URLs are shared and hand-edited: an unknown sort or
94
+ * a junk page falls back to its default rather than refusing the page. A host
95
+ * may still layer its own tolerance on top — fndvit-website trims and caps
96
+ * lengths in zod, vit-brain clamps nothing further — but the names, and what
97
+ * an absent or bad value means, are answered once, here.
98
+ */
99
+ export declare function parseWeeklyListUrl(url: URL | string): WeeklyListFilters & {
100
+ page: number;
101
+ };
@@ -40,3 +40,49 @@
40
40
  * is the value it should answer unless it means to differ.
41
41
  */
42
42
  export const WEEKLY_LIST_DEFAULTS = { sort: 'desc', page: 1, pageSize: 12 };
43
+ /**
44
+ * The four params this list travels as, in the order a URL reads.
45
+ *
46
+ * Exported so a host's query schema can be bound to them rather than agreeing
47
+ * by coincidence: fndvit-website's `weeklyListQuerySchema` names all four as
48
+ * zod fields, and nothing said they were these four.
49
+ */
50
+ export const WEEKLY_LIST_PARAMS = ['q', 'theme', 'sort', 'page'];
51
+ /**
52
+ * The READ half of the weeklies URL contract — what a URL this package wrote
53
+ * means when it comes back.
54
+ *
55
+ * `WEEKLY_LIST_DEFAULTS` gave the VALUES one owner and both hosts derive from
56
+ * it correctly. The param NAMES and the parse had none: they were spelled four
57
+ * times across three repositories — `toQuery`/`hrefFor` here, a `parseListUrl`
58
+ * declared inside this package's own test, `weeklyListQuerySchema` in
59
+ * fndvit-website and `weeklyQuery` in vit-brain — and the two repositories
60
+ * cannot import each other, so nothing could disagree out loud. vit-brain's
61
+ * docblock states the failure exactly: rename a param here and the read side
62
+ * drops the filter silently while the write side keeps producing it.
63
+ *
64
+ * Worse, the round-trip case that exists to catch precisely that
65
+ * ("builds URLs that parse back to the filters they were built from") crossed
66
+ * the write half against the stand-in in the test file, not against either
67
+ * real reader. It could not fail for the reason it was written.
68
+ *
69
+ * TOLERANT, because these URLs are shared and hand-edited: an unknown sort or
70
+ * a junk page falls back to its default rather than refusing the page. A host
71
+ * may still layer its own tolerance on top — fndvit-website trims and caps
72
+ * lengths in zod, vit-brain clamps nothing further — but the names, and what
73
+ * an absent or bad value means, are answered once, here.
74
+ */
75
+ export function parseWeeklyListUrl(url) {
76
+ // A relative href (`hrefFor` writes one) needs a base to parse against; it
77
+ // is never read, only discarded with the rest of the origin.
78
+ const params = (typeof url === 'string' ? new URL(url, 'https://weekly-list.invalid') : url)
79
+ .searchParams;
80
+ const sort = params.get('sort');
81
+ const page = Math.trunc(Number(params.get('page')));
82
+ return {
83
+ q: params.get('q') ?? '',
84
+ theme: params.get('theme'),
85
+ sort: sort === 'asc' || sort === 'desc' ? sort : WEEKLY_LIST_DEFAULTS.sort,
86
+ page: page >= WEEKLY_LIST_DEFAULTS.page ? page : WEEKLY_LIST_DEFAULTS.page
87
+ };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -1,42 +0,0 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import Sidebar, { type SidebarItem } from './Sidebar.svelte';
4
-
5
- interface Props {
6
- /** Rail entries, already permission-filtered by the host. */
7
- items: SidebarItem[];
8
- /** Accessible name of the sidebar nav landmark. */
9
- navLabel?: string;
10
- logo?: Snippet;
11
- /** Bottom of the rail — the host's logout form. */
12
- footer?: Snippet;
13
- /** The page content. */
14
- children: Snippet;
15
- /** Highlighting override for tests and stories. */
16
- url?: URL;
17
- }
18
-
19
- let { items, navLabel = 'Principal', logo, footer, children, url = undefined }: Props = $props();
20
- </script>
21
-
22
- <div class="shell">
23
- <Sidebar {items} label={navLabel} {logo} {footer} {url} />
24
- <main class="content">
25
- {@render children()}
26
- </main>
27
- </div>
28
-
29
- <style>
30
- /* The sidebar is fixed at 4.5rem; the content pays for it once, here,
31
- instead of every page knowing the rail's width. */
32
- .content {
33
- margin-left: 4.5rem;
34
- min-height: 100vh;
35
- }
36
-
37
- @media print {
38
- .content {
39
- margin-left: 0;
40
- }
41
- }
42
- </style>
@@ -1,18 +0,0 @@
1
- import type { Snippet } from 'svelte';
2
- import { type SidebarItem } from './Sidebar.svelte';
3
- interface Props {
4
- /** Rail entries, already permission-filtered by the host. */
5
- items: SidebarItem[];
6
- /** Accessible name of the sidebar nav landmark. */
7
- navLabel?: string;
8
- logo?: Snippet;
9
- /** Bottom of the rail — the host's logout form. */
10
- footer?: Snippet;
11
- /** The page content. */
12
- children: Snippet;
13
- /** Highlighting override for tests and stories. */
14
- url?: URL;
15
- }
16
- declare const AdminShell: import("svelte").Component<Props, {}, "">;
17
- type AdminShell = ReturnType<typeof AdminShell>;
18
- export default AdminShell;