@dirwell/theme-ledger 0.0.1-alpha.1 → 0.0.2

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.
@@ -1,4 +1,4 @@
1
1
  import type {Component, Snippet} from 'svelte';
2
2
  import type {ThemePage, RowContext} from '../model.mjs';
3
- export interface LedgerProps {readonly model: ThemePage; readonly header?: Snippet<[{model: ThemePage}]>; readonly row?: Snippet<[RowContext]>; readonly footer?: Snippet<[{model: ThemePage}]>;}
3
+ export interface LedgerProps {readonly model: ThemePage; readonly header?: Snippet<[{model: ThemePage}]>; readonly row?: Snippet<[RowContext]>; readonly actions?: Snippet<[RowContext]>; readonly footer?: Snippet<[{model: ThemePage}]>;}
4
4
  declare const Ledger: Component<LedgerProps>; export default Ledger;
@@ -0,0 +1,255 @@
1
+ // ../shared-theme/src/customization.ts
2
+ var defaultLabels = {
3
+ other: "other",
4
+ crosswaveKeyboardHelp: "Arrow keys navigate \xB7 Enter confirms \xB7 Backspace returns",
5
+ brokenLink: "broken link",
6
+ unavailableLink: "unavailable link",
7
+ cycleLink: "cycle link",
8
+ azure: "azure",
9
+ violet: "violet",
10
+ amber: "amber",
11
+ rose: "rose",
12
+ jade: "jade",
13
+ graphite: "graphite",
14
+ allFiles: "All files",
15
+ categoryFolders: "Folders",
16
+ categoryPhotos: "Photos",
17
+ categoryMusic: "Music",
18
+ categoryVideos: "Videos",
19
+ categoryDocuments: "Documents",
20
+ categoryOther: "Other",
21
+ indexOf: "Index of {path}",
22
+ home: "Home",
23
+ breadcrumb: "Breadcrumb",
24
+ archive: "File archive",
25
+ searchDirectory: "Search this directory",
26
+ searchFiles: "Search files",
27
+ search: "Search",
28
+ types: "Types",
29
+ directory: "directory",
30
+ file: "file",
31
+ link: "link",
32
+ folder: "Folder",
33
+ sort: "Sort",
34
+ name: "Name",
35
+ size: "Size",
36
+ modified: "Modified",
37
+ names: "Names",
38
+ natural: "Natural",
39
+ locale: "Locale",
40
+ unicode: "Unicode",
41
+ order: "Order",
42
+ ascending: "Ascending",
43
+ descending: "Descending",
44
+ directoriesFirst: "Folders first",
45
+ appearance: "Appearance",
46
+ system: "System",
47
+ light: "Light",
48
+ dark: "Dark",
49
+ searchArchive: "Search archive",
50
+ archiveSearchInput: "Search archive files",
51
+ entries: "{count} entries",
52
+ pageEntries: "{total} entries \xB7 {count} on this page",
53
+ loading: "Loading\u2026",
54
+ noMatches: "No matching entries.",
55
+ empty: "This directory is empty.",
56
+ pagination: "Pagination",
57
+ previous: "Previous",
58
+ next: "Next",
59
+ page: "Page {page} of {pages}",
60
+ keyboardHelp: "Arrow keys select files \xB7 Home / End jump \xB7 / searches \xB7 Backspace opens parent",
61
+ modifiedTime: "Modified time",
62
+ close: "Close",
63
+ unknown: "Unknown",
64
+ unavailable: "Target unavailable",
65
+ beginArchive: "Enter a search to begin.",
66
+ searchingArchive: "Searching archive\u2026",
67
+ archiveResults: "{count} results",
68
+ truncatedResults: "Showing {shown} of {count} results",
69
+ archiveError: "Could not load archive search. Try again.",
70
+ listingError: "Could not load complete directory data. Native page links remain available.",
71
+ folderError: "Could not open the folder. Its native link remains available.",
72
+ updatesError: "Live updates disconnected. Refresh to retry.",
73
+ retry: "Retry",
74
+ openNative: "Open folder directly",
75
+ categories: "File categories",
76
+ items: "{count} items",
77
+ filesIn: "Files in {path}",
78
+ controller: "Controller \xB7 Confirm / Back",
79
+ color: "Color",
80
+ pauseMotion: "Pause motion",
81
+ resumeMotion: "Resume motion",
82
+ copyLink: "Copy link",
83
+ copyPath: "Copy path",
84
+ download: "Download",
85
+ copied: "Copied.",
86
+ copyError: "Could not copy. Use the native link or select the path.",
87
+ repository: "Repository",
88
+ author: "by",
89
+ target: "Target",
90
+ newTab: "opens in a new tab"
91
+ };
92
+ function resolveCustomization(options = {}) {
93
+ const language = options.language === void 0 ? "en" : options.language, locale = options.locale === void 0 ? "en-US" : options.locale, timeZone = options.timeZone === void 0 ? "UTC" : options.timeZone;
94
+ for (const [key, value] of Object.entries({ language, locale, timeZone }))
95
+ if (typeof value !== "string" || !value)
96
+ throw new TypeError(`${key} must be a non-empty string`);
97
+ new Intl.DateTimeFormat(locale, { timeZone });
98
+ const direction = options.direction ?? "ltr";
99
+ if (direction !== "ltr" && direction !== "rtl")
100
+ throw new TypeError("direction must be ltr or rtl");
101
+ const labels = { ...defaultLabels };
102
+ if (options.labels !== void 0) {
103
+ if (typeof options.labels !== "object" || options.labels === null || Array.isArray(options.labels))
104
+ throw new TypeError("labels must be an object");
105
+ for (const [key, value] of Object.entries(options.labels)) {
106
+ if (!Object.hasOwn(labels, key) || typeof value !== "string")
107
+ throw new TypeError(`Unsupported label or value: ${key}`);
108
+ Object.assign(labels, { [key]: value });
109
+ }
110
+ }
111
+ const brand = options.brand ?? {};
112
+ if (typeof brand !== "object" || brand === null || Array.isArray(brand))
113
+ throw new TypeError("brand must be an object");
114
+ for (const [key, value] of Object.entries(brand)) {
115
+ if (!["name", "logoUrl", "logoAlt", "footerText", "footerUrl", "faviconUrl"].includes(key) || typeof value !== "string")
116
+ throw new TypeError(`Unsupported brand option or value: ${key}`);
117
+ if (key.endsWith("Url") && !/^https?:\/\//i.test(value) && !(value.startsWith("/") && !value.startsWith("//")))
118
+ throw new TypeError(`brand.${key} must be an HTTP(S) or root-relative URL`);
119
+ }
120
+ const columns = options.columns ?? ["name", "size", "modified"];
121
+ if (!Array.isArray(columns) || !columns.includes("name"))
122
+ throw new TypeError("columns must contain name and unique supported columns");
123
+ const columnIds = /* @__PURE__ */ new Set();
124
+ for (const column of columns) {
125
+ let id;
126
+ if (typeof column === "string") {
127
+ if (!["name", "size", "modified"].includes(column)) throw new TypeError("Unsupported column");
128
+ id = column;
129
+ } else {
130
+ if (typeof column !== "object" || column === null || Object.keys(column).some((key) => !["dataKey", "label"].includes(key)) || typeof column.dataKey !== "string" || !column.dataKey || typeof column.label !== "string" || !column.label)
131
+ throw new TypeError("Data columns require dataKey and label");
132
+ id = "data:" + column.dataKey;
133
+ }
134
+ if (columnIds.has(id)) throw new TypeError("columns must contain unique supported columns");
135
+ columnIds.add(id);
136
+ }
137
+ const actions = options.actions ?? [];
138
+ if (!Array.isArray(actions) || new Set(actions).size !== actions.length || actions.some((key) => !["copy-link", "copy-path", "download"].includes(key)))
139
+ throw new TypeError("actions must contain unique supported actions");
140
+ const cssVariables = options.cssVariables ?? {};
141
+ if (typeof cssVariables !== "object" || cssVariables === null || Array.isArray(cssVariables))
142
+ throw new TypeError("cssVariables must be an object");
143
+ for (const [key, value] of Object.entries(cssVariables))
144
+ if (!/^--[a-zA-Z][a-zA-Z0-9_-]*$/.test(key) || typeof value !== "string" || /[{};<>]/.test(value))
145
+ throw new TypeError(`Invalid CSS variable: ${key}`);
146
+ return {
147
+ language,
148
+ dateOptions: {
149
+ ...options.locale === void 0 ? {} : { locale: options.locale },
150
+ ...options.timeZone === void 0 ? {} : { timeZone: options.timeZone }
151
+ },
152
+ direction,
153
+ locale,
154
+ timeZone,
155
+ labels,
156
+ brand: { ...brand },
157
+ columns: columns.map((column) => typeof column === "string" ? column : { ...column }),
158
+ actions: [...actions],
159
+ cssVariables: { ...cssVariables }
160
+ };
161
+ }
162
+ var defaultCustomization = resolveCustomization();
163
+ function label(ui, key, values = {}) {
164
+ return ui.labels[key].replace(
165
+ /\{([a-zA-Z]+)\}/g,
166
+ (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match
167
+ );
168
+ }
169
+ var dateFormats = /* @__PURE__ */ new Map();
170
+ function formattedDate(value, ui, local = false) {
171
+ const date = new Date(value);
172
+ if (Number.isNaN(date.getTime())) return ui.labels.unknown;
173
+ if (ui.dateOptions.locale === void 0 && ui.dateOptions.timeZone === void 0)
174
+ return local ? date.toLocaleString(void 0, { dateStyle: "short", timeStyle: "short" }) : date.toISOString().slice(0, 16).replace("T", " ") + " UTC";
175
+ const locale = ui.dateOptions.locale, timeZone = ui.dateOptions.timeZone ?? (local ? void 0 : "UTC");
176
+ const key = String(locale) + "\0" + String(timeZone);
177
+ let formatter = dateFormats.get(key);
178
+ if (formatter === void 0) {
179
+ formatter = new Intl.DateTimeFormat(locale, {
180
+ timeZone,
181
+ dateStyle: "short",
182
+ timeStyle: "short"
183
+ });
184
+ if (dateFormats.size >= 32) dateFormats.delete(dateFormats.keys().next().value);
185
+ dateFormats.set(key, formatter);
186
+ }
187
+ return formatter.format(date);
188
+ }
189
+ function customizationCss(ui) {
190
+ const declarations = Object.entries(ui.cssVariables).map(([key, value]) => `${key}:${value}`).join(";");
191
+ return declarations ? `:root:root{${declarations}}${ui.cssVariables["--dw-font-family"] ? "body{font-family:var(--dw-font-family)}" : ""}` : "";
192
+ }
193
+ async function copyEntryAction(action, entry) {
194
+ const value = action === "copy-link" && entry.href ? new URL(entry.href, document.baseURI).href : entry.id;
195
+ if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
196
+ await navigator.clipboard.writeText(value);
197
+ }
198
+ function pickCustomization(options) {
199
+ const result = {};
200
+ for (const key of [
201
+ "language",
202
+ "direction",
203
+ "locale",
204
+ "timeZone",
205
+ "labels",
206
+ "brand",
207
+ "columns",
208
+ "actions",
209
+ "cssVariables"
210
+ ])
211
+ if (options[key] !== void 0) {
212
+ const value = options[key];
213
+ Object.assign(result, {
214
+ [key]: Array.isArray(value) ? value.map((item) => typeof item === "object" ? { ...item } : item) : typeof value === "object" && value !== null ? { ...value } : value
215
+ });
216
+ }
217
+ return Object.keys(result).length ? result : void 0;
218
+ }
219
+ function assertThemeOptions(value, extraKeys) {
220
+ if (typeof value !== "object" || value === null || Array.isArray(value))
221
+ throw new TypeError("Theme options must be an object");
222
+ const allowed = [
223
+ "language",
224
+ "direction",
225
+ "locale",
226
+ "timeZone",
227
+ "labels",
228
+ "brand",
229
+ "columns",
230
+ "actions",
231
+ "cssVariables",
232
+ ...extraKeys
233
+ ];
234
+ for (const key of Object.keys(value))
235
+ if (!allowed.includes(key)) throw new TypeError(`Unsupported theme option: ${key}`);
236
+ }
237
+ function columnLabel(column, ui) {
238
+ return typeof column === "string" ? ui.labels[column] : column.label;
239
+ }
240
+ function publicDataValue(value) {
241
+ return value === void 0 || value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
242
+ }
243
+ export {
244
+ assertThemeOptions,
245
+ columnLabel,
246
+ copyEntryAction,
247
+ customizationCss,
248
+ defaultCustomization,
249
+ defaultLabels,
250
+ formattedDate,
251
+ label,
252
+ pickCustomization,
253
+ publicDataValue,
254
+ resolveCustomization
255
+ };
@@ -0,0 +1,28 @@
1
+ // ../shared-theme/src/live-search.ts
2
+ async function queryLiveArchive(href, query, signal) {
3
+ const url = new URL(href, document.baseURI);
4
+ if (url.origin !== location.origin) throw new Error("Unsafe search endpoint");
5
+ url.searchParams.set("q", query);
6
+ const response = await fetch(url, { signal, cache: "no-store" });
7
+ if (!response.ok) throw new Error("Archive search unavailable");
8
+ const data = await response.json();
9
+ if (typeof data !== "object" || data === null || !("version" in data) || data.version !== 1 || !("results" in data) || !Array.isArray(data.results) || data.results.length > 1e4 || !("truncated" in data) || typeof data.truncated !== "boolean")
10
+ throw new Error("Invalid archive results");
11
+ const records = data.results.map((entry) => {
12
+ if (typeof entry !== "object" || entry === null || !("name" in entry) || typeof entry.name !== "string" || !("path" in entry) || typeof entry.path !== "string" || !("kind" in entry) || !["file", "directory"].includes(String(entry.kind)) || !("href" in entry) || entry.href !== null && typeof entry.href !== "string")
13
+ throw new Error("Invalid archive record");
14
+ const target = entry.href === null ? null : new URL(entry.href, url);
15
+ if (target && !["http:", "https:"].includes(target.protocol))
16
+ throw new Error("Unsafe archive link");
17
+ return {
18
+ name: entry.name,
19
+ path: entry.path,
20
+ href: target?.href ?? null,
21
+ exitsExplorer: entry.kind !== "directory"
22
+ };
23
+ });
24
+ return { records, truncated: data.truncated };
25
+ }
26
+ export {
27
+ queryLiveArchive
28
+ };
@@ -72,6 +72,7 @@ function entry(value, base) {
72
72
  };
73
73
  }
74
74
  return {
75
+ ...value.data === void 0 ? {} : { data: copyJsonObject(value.data) },
75
76
  ...value.presentation === void 0 ? {} : { presentation: copyJsonObject(value.presentation) },
76
77
  id: string(value.id, "id"),
77
78
  name: string(value.name, "name"),
@@ -175,6 +176,169 @@ function parseListingEntries(value, baseHref, maxEntries = 1e5) {
175
176
  return result;
176
177
  }
177
178
 
179
+ // ../shared-theme/src/customization.ts
180
+ var defaultLabels = {
181
+ other: "other",
182
+ crosswaveKeyboardHelp: "Arrow keys navigate \xB7 Enter confirms \xB7 Backspace returns",
183
+ brokenLink: "broken link",
184
+ unavailableLink: "unavailable link",
185
+ cycleLink: "cycle link",
186
+ azure: "azure",
187
+ violet: "violet",
188
+ amber: "amber",
189
+ rose: "rose",
190
+ jade: "jade",
191
+ graphite: "graphite",
192
+ allFiles: "All files",
193
+ categoryFolders: "Folders",
194
+ categoryPhotos: "Photos",
195
+ categoryMusic: "Music",
196
+ categoryVideos: "Videos",
197
+ categoryDocuments: "Documents",
198
+ categoryOther: "Other",
199
+ indexOf: "Index of {path}",
200
+ home: "Home",
201
+ breadcrumb: "Breadcrumb",
202
+ archive: "File archive",
203
+ searchDirectory: "Search this directory",
204
+ searchFiles: "Search files",
205
+ search: "Search",
206
+ types: "Types",
207
+ directory: "directory",
208
+ file: "file",
209
+ link: "link",
210
+ folder: "Folder",
211
+ sort: "Sort",
212
+ name: "Name",
213
+ size: "Size",
214
+ modified: "Modified",
215
+ names: "Names",
216
+ natural: "Natural",
217
+ locale: "Locale",
218
+ unicode: "Unicode",
219
+ order: "Order",
220
+ ascending: "Ascending",
221
+ descending: "Descending",
222
+ directoriesFirst: "Folders first",
223
+ appearance: "Appearance",
224
+ system: "System",
225
+ light: "Light",
226
+ dark: "Dark",
227
+ searchArchive: "Search archive",
228
+ archiveSearchInput: "Search archive files",
229
+ entries: "{count} entries",
230
+ pageEntries: "{total} entries \xB7 {count} on this page",
231
+ loading: "Loading\u2026",
232
+ noMatches: "No matching entries.",
233
+ empty: "This directory is empty.",
234
+ pagination: "Pagination",
235
+ previous: "Previous",
236
+ next: "Next",
237
+ page: "Page {page} of {pages}",
238
+ keyboardHelp: "Arrow keys select files \xB7 Home / End jump \xB7 / searches \xB7 Backspace opens parent",
239
+ modifiedTime: "Modified time",
240
+ close: "Close",
241
+ unknown: "Unknown",
242
+ unavailable: "Target unavailable",
243
+ beginArchive: "Enter a search to begin.",
244
+ searchingArchive: "Searching archive\u2026",
245
+ archiveResults: "{count} results",
246
+ truncatedResults: "Showing {shown} of {count} results",
247
+ archiveError: "Could not load archive search. Try again.",
248
+ listingError: "Could not load complete directory data. Native page links remain available.",
249
+ folderError: "Could not open the folder. Its native link remains available.",
250
+ updatesError: "Live updates disconnected. Refresh to retry.",
251
+ retry: "Retry",
252
+ openNative: "Open folder directly",
253
+ categories: "File categories",
254
+ items: "{count} items",
255
+ filesIn: "Files in {path}",
256
+ controller: "Controller \xB7 Confirm / Back",
257
+ color: "Color",
258
+ pauseMotion: "Pause motion",
259
+ resumeMotion: "Resume motion",
260
+ copyLink: "Copy link",
261
+ copyPath: "Copy path",
262
+ download: "Download",
263
+ copied: "Copied.",
264
+ copyError: "Could not copy. Use the native link or select the path.",
265
+ repository: "Repository",
266
+ author: "by",
267
+ target: "Target",
268
+ newTab: "opens in a new tab"
269
+ };
270
+ function resolveCustomization(options = {}) {
271
+ const language = options.language === void 0 ? "en" : options.language, locale = options.locale === void 0 ? "en-US" : options.locale, timeZone = options.timeZone === void 0 ? "UTC" : options.timeZone;
272
+ for (const [key, value] of Object.entries({ language, locale, timeZone }))
273
+ if (typeof value !== "string" || !value)
274
+ throw new TypeError(`${key} must be a non-empty string`);
275
+ new Intl.DateTimeFormat(locale, { timeZone });
276
+ const direction = options.direction ?? "ltr";
277
+ if (direction !== "ltr" && direction !== "rtl")
278
+ throw new TypeError("direction must be ltr or rtl");
279
+ const labels = { ...defaultLabels };
280
+ if (options.labels !== void 0) {
281
+ if (typeof options.labels !== "object" || options.labels === null || Array.isArray(options.labels))
282
+ throw new TypeError("labels must be an object");
283
+ for (const [key, value] of Object.entries(options.labels)) {
284
+ if (!Object.hasOwn(labels, key) || typeof value !== "string")
285
+ throw new TypeError(`Unsupported label or value: ${key}`);
286
+ Object.assign(labels, { [key]: value });
287
+ }
288
+ }
289
+ const brand = options.brand ?? {};
290
+ if (typeof brand !== "object" || brand === null || Array.isArray(brand))
291
+ throw new TypeError("brand must be an object");
292
+ for (const [key, value] of Object.entries(brand)) {
293
+ if (!["name", "logoUrl", "logoAlt", "footerText", "footerUrl", "faviconUrl"].includes(key) || typeof value !== "string")
294
+ throw new TypeError(`Unsupported brand option or value: ${key}`);
295
+ if (key.endsWith("Url") && !/^https?:\/\//i.test(value) && !(value.startsWith("/") && !value.startsWith("//")))
296
+ throw new TypeError(`brand.${key} must be an HTTP(S) or root-relative URL`);
297
+ }
298
+ const columns = options.columns ?? ["name", "size", "modified"];
299
+ if (!Array.isArray(columns) || !columns.includes("name"))
300
+ throw new TypeError("columns must contain name and unique supported columns");
301
+ const columnIds = /* @__PURE__ */ new Set();
302
+ for (const column of columns) {
303
+ let id;
304
+ if (typeof column === "string") {
305
+ if (!["name", "size", "modified"].includes(column)) throw new TypeError("Unsupported column");
306
+ id = column;
307
+ } else {
308
+ if (typeof column !== "object" || column === null || Object.keys(column).some((key) => !["dataKey", "label"].includes(key)) || typeof column.dataKey !== "string" || !column.dataKey || typeof column.label !== "string" || !column.label)
309
+ throw new TypeError("Data columns require dataKey and label");
310
+ id = "data:" + column.dataKey;
311
+ }
312
+ if (columnIds.has(id)) throw new TypeError("columns must contain unique supported columns");
313
+ columnIds.add(id);
314
+ }
315
+ const actions = options.actions ?? [];
316
+ if (!Array.isArray(actions) || new Set(actions).size !== actions.length || actions.some((key) => !["copy-link", "copy-path", "download"].includes(key)))
317
+ throw new TypeError("actions must contain unique supported actions");
318
+ const cssVariables = options.cssVariables ?? {};
319
+ if (typeof cssVariables !== "object" || cssVariables === null || Array.isArray(cssVariables))
320
+ throw new TypeError("cssVariables must be an object");
321
+ for (const [key, value] of Object.entries(cssVariables))
322
+ if (!/^--[a-zA-Z][a-zA-Z0-9_-]*$/.test(key) || typeof value !== "string" || /[{};<>]/.test(value))
323
+ throw new TypeError(`Invalid CSS variable: ${key}`);
324
+ return {
325
+ language,
326
+ dateOptions: {
327
+ ...options.locale === void 0 ? {} : { locale: options.locale },
328
+ ...options.timeZone === void 0 ? {} : { timeZone: options.timeZone }
329
+ },
330
+ direction,
331
+ locale,
332
+ timeZone,
333
+ labels,
334
+ brand: { ...brand },
335
+ columns: columns.map((column) => typeof column === "string" ? column : { ...column }),
336
+ actions: [...actions],
337
+ cssVariables: { ...cssVariables }
338
+ };
339
+ }
340
+ var defaultCustomization = resolveCustomization();
341
+
178
342
  // ../shared-theme/src/navigation.ts
179
343
  async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false, attempt = 0) {
180
344
  const source = new URL(dataHref, document.baseURI);
@@ -190,6 +354,20 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
190
354
  throw new ListingDataError("Unsupported folder snapshot");
191
355
  if (data.metadataHead !== void 0 && typeof data.metadataHead !== "string")
192
356
  throw new ListingDataError("Invalid folder metadata");
357
+ const sort = data.sort;
358
+ if (!sort || typeof sort !== "object" || !["name", "size", "modified"].includes(String(sort.field)) || !["asc", "desc"].includes(String(sort.direction)) || !["natural", "locale", "unicode"].includes(String(sort.nameMode)) || typeof sort.directoriesFirst !== "boolean")
359
+ throw new ListingDataError("Invalid folder order");
360
+ if (theme === "crosswave") {
361
+ const categories = data.categories;
362
+ const ids = /* @__PURE__ */ new Set();
363
+ if (!Array.isArray(categories) || categories.length === 0 || categories.some((category) => {
364
+ if (typeof category !== "object" || category === null || typeof category.id !== "string" || !category.id || ids.has(category.id) || typeof category.label !== "string" || typeof category.svg !== "string")
365
+ return true;
366
+ ids.add(category.id);
367
+ return false;
368
+ }))
369
+ throw new ListingDataError("Invalid folder categories");
370
+ }
193
371
  const base = document.querySelector("base")?.href ?? pageHref;
194
372
  const link = (raw) => {
195
373
  if (raw === null) return null;
@@ -209,8 +387,22 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
209
387
  } : {}
210
388
  }));
211
389
  if (entries.length > Number(data.total)) throw new ListingDataError("Invalid folder count");
390
+ if (data.page !== null) {
391
+ const page = data.page;
392
+ if (!page || typeof page !== "object" || !Number.isSafeInteger(page.number) || Number(page.number) < 1 || !Number.isSafeInteger(page.count) || Number(page.count) < Number(page.number) || !Number.isSafeInteger(page.offset) || Number(page.offset) < 0 || Number(page.offset) > Number(data.total) || page.total !== data.total)
393
+ throw new ListingDataError("Invalid folder pagination");
394
+ data.page = {
395
+ ...page,
396
+ href: link(page.href),
397
+ previousHref: link(page.previousHref),
398
+ nextHref: link(page.nextHref)
399
+ };
400
+ }
401
+ if (data.ui !== void 0)
402
+ resolveCustomization(data.ui);
212
403
  const result = {
213
404
  ...data,
405
+ searchQueryHref: link(data.searchQueryHref ?? null),
214
406
  updatesHref: link(data.updatesHref ?? null),
215
407
  entries,
216
408
  dataHref: source.href,
@@ -225,8 +417,11 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
225
417
  return { name: raw.name, href: link(raw.href), dataHref: link(raw.dataHref) };
226
418
  })
227
419
  };
228
- if (fresh && result.manifestHref) {
229
- const loaded = await loadListing(result.manifestHref, { signal, cache: "no-store" });
420
+ if (result.manifestHref) {
421
+ const loaded = await loadListing(result.manifestHref, {
422
+ signal,
423
+ cache: fresh ? "no-store" : "default"
424
+ });
230
425
  if (loaded.manifest.total !== data.total || data.listingRevision !== void 0 && loaded.manifest.revision !== data.listingRevision) {
231
426
  if (attempt < 2) return fetchThemePage(dataHref, pageHref, theme, signal, true, attempt + 1);
232
427
  throw new ListingDataError("Folder changed while refreshing");
@@ -0,0 +1,128 @@
1
+ // ../shared-theme/src/view-state.ts
2
+ var supportedTypes = ["directory", "file", "link"];
3
+ function validSort(value) {
4
+ if (typeof value !== "object" || value === null) return false;
5
+ const sort = value;
6
+ return ["name", "size", "modified"].includes(String(sort.field)) && ["asc", "desc"].includes(String(sort.direction)) && ["natural", "locale", "unicode"].includes(String(sort.nameMode)) && typeof sort.directoriesFirst === "boolean";
7
+ }
8
+ function position(value) {
9
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(value, 1e9)) : 0;
10
+ }
11
+ function readView(href, theme, path, defaults, categories = [], historyState = null) {
12
+ const params = new URL(href).searchParams;
13
+ const result = {
14
+ ...defaults,
15
+ query: (params.get("dw.q") ?? "").slice(0, 4096),
16
+ selected: null,
17
+ windowY: 0,
18
+ listTop: 0
19
+ };
20
+ if (theme === "ledger") {
21
+ const parts = params.get("dw.sort")?.split(":");
22
+ if (parts?.length === 4) {
23
+ const sort = {
24
+ field: parts[0],
25
+ direction: parts[1],
26
+ nameMode: parts[2],
27
+ directoriesFirst: parts[3] === "folders"
28
+ };
29
+ if (["folders", "mixed"].includes(parts[3]) && validSort(sort)) result.sort = sort;
30
+ }
31
+ if (params.has("dw.types")) {
32
+ const types = params.get("dw.types").split(",").filter(Boolean);
33
+ if (types.every((type) => supportedTypes.includes(type))) result.types = [...new Set(types)];
34
+ }
35
+ } else {
36
+ const category = params.get("dw.category");
37
+ if (category !== null && categories.includes(category)) result.category = category;
38
+ }
39
+ if (typeof historyState === "object" && historyState !== null && "dirwell" in historyState) {
40
+ const state = historyState.dirwell;
41
+ if (typeof state === "object" && state !== null && "version" in state && state.version === 1 && "theme" in state && state.theme === theme && "path" in state && state.path === path && "view" in state && typeof state.view === "object" && state.view !== null) {
42
+ const view = state.view;
43
+ result.selected = typeof view.selected === "string" && view.selected.length <= 4096 ? view.selected : null;
44
+ result.windowY = position(view.windowY);
45
+ result.listTop = position(view.listTop);
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+ function viewHref(href, theme, view, defaults) {
51
+ const url = new URL(href);
52
+ for (const key of ["dw.q", "dw.sort", "dw.types", "dw.category"]) url.searchParams.delete(key);
53
+ if (view.query) url.searchParams.set("dw.q", view.query.slice(0, 4096));
54
+ if (theme === "ledger") {
55
+ if (view.sort && ["field", "direction", "nameMode", "directoriesFirst"].some(
56
+ (key) => view.sort[key] !== defaults.sort?.[key]
57
+ )) {
58
+ const sort = view.sort;
59
+ url.searchParams.set(
60
+ "dw.sort",
61
+ `${sort.field}:${sort.direction}:${sort.nameMode}:${sort.directoriesFirst ? "folders" : "mixed"}`
62
+ );
63
+ }
64
+ if (view.types && supportedTypes.some(
65
+ (type) => view.types.includes(type) !== (defaults.types ?? supportedTypes).includes(type)
66
+ ))
67
+ url.searchParams.set("dw.types", view.types.join(","));
68
+ } else if (view.category && view.category !== defaults.category)
69
+ url.searchParams.set("dw.category", view.category);
70
+ return url.href;
71
+ }
72
+ function createViewHistory(theme) {
73
+ let committedHref = location.href;
74
+ let committedState = history.state;
75
+ const stateFor = (dataHref, path, view) => ({
76
+ ...typeof history.state === "object" && history.state !== null ? history.state : {},
77
+ dataHref,
78
+ dirwell: {
79
+ version: 1,
80
+ theme,
81
+ path,
82
+ ...view === void 0 ? {} : {
83
+ view: {
84
+ query: view.query,
85
+ ...view.sort === void 0 ? {} : { sort: { ...view.sort } },
86
+ ...view.types === void 0 ? {} : { types: [...view.types] },
87
+ category: view.category ?? null,
88
+ selected: view.selected ?? null,
89
+ windowY: view.windowY ?? 0,
90
+ listTop: view.listTop ?? 0
91
+ }
92
+ }
93
+ }
94
+ });
95
+ return {
96
+ save(dataHref, path, view, defaults) {
97
+ if (location.href !== committedHref) return;
98
+ const href = viewHref(committedHref, theme, view, defaults);
99
+ const state = stateFor(dataHref, path, view);
100
+ try {
101
+ history.replaceState(state, "", href);
102
+ committedHref = href;
103
+ committedState = state;
104
+ } catch {
105
+ }
106
+ },
107
+ commit(dataHref, path, href, push) {
108
+ const state = stateFor(dataHref, path);
109
+ if (push) history.pushState(state, "", href);
110
+ else history.replaceState(state, "", href);
111
+ committedHref = href;
112
+ committedState = state;
113
+ },
114
+ rollback() {
115
+ if (location.href !== committedHref) {
116
+ try {
117
+ history.replaceState(committedState, "", committedHref);
118
+ } catch {
119
+ }
120
+ }
121
+ }
122
+ };
123
+ }
124
+ export {
125
+ createViewHistory,
126
+ readView,
127
+ viewHref
128
+ };
package/dist/styles.css CHANGED
@@ -1,4 +1,9 @@
1
1
 
2
+ .entry-data{min-width:0;overflow-wrap:anywhere}
3
+
4
+ [data-dirwell-actions] button,[data-dirwell-actions] a{min-height:44px;padding:8px 12px;border:1px solid var(--rule);border-radius:4px;background:var(--control);color:var(--ink)}
5
+ @media(max-width:720px){.entry[data-custom-columns=true]{grid-template-columns:minmax(0,1fr)!important}.entry-head[data-custom-columns=true]{display:none}.entry[data-custom-columns=true]>.kind,.entry[data-custom-columns=true]>.timestamp-button{display:block!important}}
6
+
2
7
  :root {
3
8
  color-scheme: light dark;
4
9
  --dw-control-height: 2.75rem;
@@ -634,6 +639,7 @@ summary:focus-visible {
634
639
  background: var(--accent);
635
640
  color: var(--surface);
636
641
  }
642
+ .brand-footer { padding: 0.7rem 1.4rem; overflow-wrap: anywhere; }
637
643
  footer {
638
644
  display: flex;
639
645
  align-items: center;