quicklook-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,3 +1,7 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan T. Sott
4
+
1
5
  Permission is hereby granted, free of charge, to any
2
6
  person obtaining a copy of this software and associated
3
7
  documentation files (the "Software"), to deal in the
package/README.markdown CHANGED
@@ -4,9 +4,9 @@
4
4
  [![Version](https://img.shields.io/npm/v/quicklook-kit?style=for-the-badge)](https://www.npmjs.org/package/quicklook-kit)
5
5
  [![Build](https://img.shields.io/github/actions/workflow/status/idleberg/quicklook-kit/ci.yml?style=for-the-badge)](https://github.com/idleberg/quicklook-kit/actions)
6
6
 
7
- > Build macOS QuickLook previews with any Vite-based web framework, no Swift required
7
+ > Build macOS Quick Look previews with any Vite-based web framework, no Swift required
8
8
 
9
- Your preview is a web page. quicklook-kit builds it with your framework, wraps it in a small native QuickLook extension and installs the resulting app. You never touch Swift or Xcode.
9
+ Your preview is a web page. quicklook-kit builds it with your framework, wraps it in a small native Quick Look extension and installs the resulting app. You never touch Swift or Xcode.
10
10
 
11
11
  Built-in support for SvelteKit, Astro, SolidStart, Nuxt and Analog. Any other framework that builds with Vite and writes a static `index.html` works through an integration.
12
12
 
@@ -34,7 +34,7 @@ Create `quicklook.config.ts` in your project root:
34
34
  import { defineConfig } from "quicklook-kit";
35
35
 
36
36
  export default defineConfig({
37
- name: "QuickLook Example",
37
+ name: "Quick Look Example",
38
38
  bundleId: "com.example.QuickLookExample",
39
39
  types: [
40
40
  {
@@ -62,8 +62,9 @@ The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a
62
62
  | `macOS` | `'14.0'` | Minimum macOS version |
63
63
  | `integrations` | `[]` | See [Integrations](#integrations) |
64
64
  | `csp` | locked down | Directives here replace the defaults of the same name |
65
- | `maxFileSize` | `10_000_000` | Larger files get QuickLook's generic preview |
65
+ | `maxFileSize` | `10_000_000` | Larger files get Quick Look's generic preview |
66
66
  | `public` | `{}` | Values the page can read at build time |
67
+ | `settings` | `{}` | Settings the user edits in the app, see [Settings](#settings) |
67
68
 
68
69
  A framework can take options as a `[name, options]` pair, e.g. `['sveltekit', { outDir: 'dist' }]` when your adapter writes somewhere other than the default.
69
70
 
@@ -99,7 +100,7 @@ or in `tsconfig.json` under `compilerOptions.types` as `"quicklook-kit/virtual"`
99
100
  ```ts
100
101
  import { browserFile, config, nativeFile } from "virtual:quicklook";
101
102
 
102
- const file = nativeFile(); // the previewed file inside QuickLook, undefined in the browser
103
+ const file = nativeFile(); // the previewed file inside Quick Look, undefined in the browser
103
104
  config.public; // values from quicklook.config.*
104
105
  ```
105
106
 
@@ -146,10 +147,51 @@ Add `.quicklook` to your `.gitignore`.
146
147
 
147
148
  - **Nothing can be loaded from the network.** The extension serves your build from inside the app with a strict Content Security Policy. Import images, fonts and CSS normally and they get bundled. The dev server only reports violations in the console, without blocking them.
148
149
  - **Inline scripts are allowed by hash.** Frameworks that bootstrap with inline `<script>` tags keep working; the hashes are added to the CSP at build time.
149
- - **Test in Safari.** It uses the same WebKit engine as QuickLook's WKWebView.
150
+ - **Test in Safari.** It uses the same WebKit engine as Quick Look's WKWebView.
150
151
  - **The light/dark theme follows the system.** Use `prefers-color-scheme` in CSS.
151
152
  - **The app is signed ad hoc.** Other people have to allow it in System Settings → Privacy & Security on first launch. Distributing it without that step requires an Apple Developer account and notarization.
152
153
 
154
+ ## Settings
155
+
156
+ `settings` declares options the user can change in the app. Each entry's `type` picks the control the app shows:
157
+
158
+ ```ts
159
+ settings: {
160
+ theme: {
161
+ type: "string",
162
+ default: "light",
163
+ enum: [{ value: "light", description: "Light" }, "dark"],
164
+ radio: true,
165
+ },
166
+ wrap: { type: "boolean", default: true, description: "Wrap **long** lines" },
167
+ fontSize: { type: "integer", default: 13, minimum: 9, maximum: 32 },
168
+ editor: {
169
+ type: "object",
170
+ properties: { tabSize: { type: "integer", default: 2, enum: [2, 4, 8] } },
171
+ },
172
+ },
173
+ ```
174
+
175
+ | Type | Control |
176
+ | ------------------------------------------- | ------------------------------------------------------------------------- |
177
+ | `boolean` | Checkbox |
178
+ | `enum` (on `string`, `integer` or `number`) | Picker, or radio buttons with `radio: true` |
179
+ | `string` | Text field |
180
+ | `integer`, `number` | Number field, clamped to `minimum`/`maximum`; `integer` rejects fractions |
181
+ | `object` | A group with a heading, for nested `properties` |
182
+
183
+ Every entry except `object` needs a `default`. `title` defaults to the key in words (`fontSize` becomes "Font Size"), `description` is inline Markdown, and `order` sorts entries. `enum` entries are plain values or `{ value, description }`. A schema with an unknown type or field, a field on the wrong type, a key containing `.`, or a default that doesn't fit its type, `enum` or bounds fails the build with the key path, e.g. `settings.editor.tabSize: default 0 is below minimum 1`.
184
+
185
+ The page reads the values, nested like the schema, with `settings()`:
186
+
187
+ ```ts
188
+ import { settings } from "virtual:quicklook";
189
+
190
+ const { wrap, editor } = settings();
191
+ ```
192
+
193
+ In the dev server, `settings()` returns the defaults.
194
+
153
195
  ## Integrations
154
196
 
155
197
  An integration is an object with a `name` and any of these hooks. They run in order: the framework first, then each entry in `integrations`.
@@ -190,7 +232,11 @@ A framework is an integration with a `build` field:
190
232
  ```ts
191
233
  framework: {
192
234
  name: 'my-framework',
193
- build: { dev: ['vite', 'dev'], command: ['vite', 'build'], outDir: 'dist' }
235
+ build: {
236
+ dev: ['vite', 'dev'],
237
+ command: ['vite', 'build'],
238
+ outDir: 'dist'
239
+ }
194
240
  }
195
241
  ```
196
242
 
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BQ-9qYNV.mjs";
2
+ import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BVPHnCWr.mjs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync } from "node:fs";
@@ -19,6 +19,20 @@ const native = fileURLToPath(new URL("../native", import.meta.url));
19
19
  const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
20
20
  const leadingV = /^v/;
21
21
  const inlineScript = /<script\b(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
22
+ /**
23
+ * The settings schema as nested arrays, each entry with its own `key`: Swift's
24
+ * JSONSerialization decodes objects into unordered dictionaries.
25
+ */
26
+ function settingsSchema(settings) {
27
+ return Object.entries(settings).map(([key, setting]) => setting.type === "object" ? {
28
+ ...setting,
29
+ key,
30
+ properties: settingsSchema(setting.properties)
31
+ } : {
32
+ ...setting,
33
+ key
34
+ });
35
+ }
22
36
  /** Runs a command, exiting with its status if it fails unless `nothrow` is set. */
23
37
  function exec([file = "", ...args], { quiet = false, nothrow = false, env = process.env } = {}) {
24
38
  const { status, error } = spawnSync(file, args, {
@@ -70,12 +84,11 @@ async function buildWeb() {
70
84
  async function build() {
71
85
  await buildWeb();
72
86
  const csp = addSources(config.csp, "script-src", ...await inlineScriptHashes());
87
+ const hasSettings = Object.keys(config.settings).length > 0;
88
+ const qlSettings = hasSettings && { QLSettings: JSON.stringify(settingsSchema(config.settings)) };
73
89
  const spec = {
74
90
  name: target,
75
- options: {
76
- deploymentTarget: { macOS },
77
- createIntermediateGroups: true
78
- },
91
+ options: { deploymentTarget: { macOS } },
79
92
  settings: { base: {
80
93
  SWIFT_VERSION: "6.0",
81
94
  MARKETING_VERSION: process.env.VERSION?.replace(leadingV, "") ?? "1.0",
@@ -87,10 +100,14 @@ async function build() {
87
100
  [target]: {
88
101
  type: "application",
89
102
  platform: "macOS",
90
- sources: [`${native}/App`, ...icon ? [{
91
- path: resolve(root, icon),
92
- buildPhase: "resources"
93
- }] : []],
103
+ sources: [
104
+ `${native}/App`,
105
+ `${native}/Shared`,
106
+ ...icon ? [{
107
+ path: resolve(root, icon),
108
+ buildPhase: "resources"
109
+ }] : []
110
+ ],
94
111
  settings: {
95
112
  PRODUCT_NAME: name,
96
113
  PRODUCT_BUNDLE_IDENTIFIER: bundleId
@@ -106,6 +123,7 @@ async function build() {
106
123
  CFBundleName: name,
107
124
  ...icon && { CFBundleIconFile: basename(icon, ".icns") },
108
125
  LSMinimumSystemVersion: macOS,
126
+ ...qlSettings,
109
127
  UTExportedTypeDeclarations: types.filter((type) => typeof type !== "string").map((type) => ({
110
128
  UTTypeIdentifier: type.id,
111
129
  UTTypeDescription: type.description,
@@ -118,11 +136,15 @@ async function build() {
118
136
  [`${target}Extension`]: {
119
137
  type: "app-extension",
120
138
  platform: "macOS",
121
- sources: [`${native}/Extension`, {
122
- path: web,
123
- type: "folder",
124
- buildPhase: "resources"
125
- }],
139
+ sources: [
140
+ `${native}/Extension`,
141
+ `${native}/Shared`,
142
+ {
143
+ path: web,
144
+ type: "folder",
145
+ buildPhase: "resources"
146
+ }
147
+ ],
126
148
  settings: { PRODUCT_BUNDLE_IDENTIFIER: `${bundleId}.Extension` },
127
149
  dependencies: [{ sdk: "QuickLookUI.framework" }, { sdk: "WebKit.framework" }],
128
150
  entitlements: {
@@ -130,7 +152,8 @@ async function build() {
130
152
  properties: {
131
153
  "com.apple.security.app-sandbox": true,
132
154
  "com.apple.security.files.user-selected.read-only": true,
133
- "com.apple.security.network.client": true
155
+ "com.apple.security.network.client": true,
156
+ ...hasSettings && { "com.apple.security.temporary-exception.shared-preference.read-only": [bundleId] }
134
157
  }
135
158
  },
136
159
  info: {
@@ -139,6 +162,7 @@ async function build() {
139
162
  CFBundleDisplayName: name,
140
163
  QLWebContentSecurityPolicy: serializeCsp(csp),
141
164
  QLMaxFileSize: config.maxFileSize,
165
+ ...qlSettings,
142
166
  NSExtension: {
143
167
  NSExtensionPointIdentifier: "com.apple.quicklook.preview",
144
168
  NSExtensionPrincipalClass: "PreviewViewController",
@@ -0,0 +1,249 @@
1
+ import { join } from "node:path";
2
+ import process from "node:process";
3
+ import { loadConfig } from "c12";
4
+ import { createJiti } from "jiti";
5
+ //#region src/settings.ts
6
+ /** The fields each setting type allows, besides `type`, `title`, `description` and `order`. */
7
+ const settingFields = {
8
+ boolean: ["default"],
9
+ string: [
10
+ "default",
11
+ "enum",
12
+ "radio"
13
+ ],
14
+ integer: [
15
+ "default",
16
+ "enum",
17
+ "radio",
18
+ "minimum",
19
+ "maximum"
20
+ ],
21
+ number: [
22
+ "default",
23
+ "enum",
24
+ "radio",
25
+ "minimum",
26
+ "maximum"
27
+ ],
28
+ object: ["properties"]
29
+ };
30
+ const commonFields = [
31
+ "type",
32
+ "title",
33
+ "description",
34
+ "order"
35
+ ];
36
+ const knownFields = /* @__PURE__ */ new Set([...commonFields, ...Object.values(settingFields).flat()]);
37
+ function isString(value) {
38
+ return typeof value === "string";
39
+ }
40
+ /** Whether `value` is a valid value of setting type `type`. */
41
+ const valueChecks = {
42
+ boolean: (value) => typeof value === "boolean",
43
+ string: isString,
44
+ integer: Number.isInteger,
45
+ number: Number.isFinite
46
+ };
47
+ /** Optional fields with a fixed type, and the problem when it's wrong. */
48
+ const fieldChecks = [
49
+ [
50
+ "title",
51
+ isString,
52
+ "title must be a string"
53
+ ],
54
+ [
55
+ "description",
56
+ isString,
57
+ "description must be a string"
58
+ ],
59
+ [
60
+ "order",
61
+ Number.isFinite,
62
+ "order must be a number"
63
+ ],
64
+ [
65
+ "minimum",
66
+ Number.isFinite,
67
+ "minimum must be a number"
68
+ ],
69
+ [
70
+ "maximum",
71
+ Number.isFinite,
72
+ "maximum must be a number"
73
+ ],
74
+ [
75
+ "radio",
76
+ (value) => typeof value === "boolean",
77
+ "radio must be true or false"
78
+ ]
79
+ ];
80
+ function isRecord(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+ /**
84
+ * Throws on anything in a `settings` schema the app can't show, naming the key
85
+ * path, e.g. `settings.editor.tabSize: default 0 is below minimum 1`.
86
+ */
87
+ function validateSettings(settings, path = "settings") {
88
+ if (!isRecord(settings)) throw new Error(`${path}: expected an object of settings`);
89
+ for (const [key, entry] of Object.entries(settings)) {
90
+ const at = `${path}.${key}`;
91
+ if (key.includes(".")) throw new Error(`${at}: keys can't contain "."`);
92
+ if (!isRecord(entry)) throw new Error(`${at}: expected an object with a type`);
93
+ const problem = settingProblem(entry);
94
+ if (problem) throw new Error(`${at}: ${problem}`);
95
+ if (entry.type === "object") validateSettings(entry.properties, at);
96
+ }
97
+ }
98
+ /** What's wrong with one entry, without looking into `properties`. */
99
+ function settingProblem(entry) {
100
+ const type = String(entry.type);
101
+ if (!(typeof entry.type === "string" && Object.hasOwn(settingFields, type))) return `unknown type ${JSON.stringify(entry.type)}`;
102
+ for (const field of Object.keys(entry)) {
103
+ if (!knownFields.has(field)) return `unknown field "${field}"`;
104
+ if (!(commonFields.includes(field) || settingFields[type]?.includes(field))) return `"${field}" isn't allowed on type "${type}"`;
105
+ }
106
+ const wrong = fieldChecks.find(([field, check]) => !(entry[field] === void 0 || check(entry[field])));
107
+ if (wrong) return wrong[2];
108
+ if (type === "object") return isRecord(entry.properties) && Object.keys(entry.properties).length > 0 ? void 0 : "an object needs non-empty properties";
109
+ return valueProblem(entry, type);
110
+ }
111
+ /** What's wrong with the default, the range and the enum of a non-object entry. */
112
+ function valueProblem(entry, type) {
113
+ if (!("default" in entry)) return "default is required";
114
+ const { default: value, minimum, maximum, radio, enum: options } = entry;
115
+ const shown = JSON.stringify(value);
116
+ if (!valueChecks[type]?.(value)) return `default ${shown} isn't of type "${type}"`;
117
+ if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) return `minimum ${minimum} is above maximum ${maximum}`;
118
+ if (typeof minimum === "number" && value < minimum) return `default ${shown} is below minimum ${minimum}`;
119
+ if (typeof maximum === "number" && value > maximum) return `default ${shown} is above maximum ${maximum}`;
120
+ if (radio && options === void 0) return "radio needs an enum";
121
+ return options === void 0 ? void 0 : enumProblem(options, value, type);
122
+ }
123
+ /** An object entry other than `{ value, description? }`. */
124
+ function malformedOption(option) {
125
+ return !("value" in option && Object.keys(option).every((field) => field === "value" || field === "description") && (option.description === void 0 || isString(option.description)));
126
+ }
127
+ function enumProblem(options, value, type) {
128
+ if (!Array.isArray(options) || options.length === 0) return "enum must be a non-empty array";
129
+ const values = [];
130
+ for (const [index, option] of options.entries()) {
131
+ if (isRecord(option) && malformedOption(option)) return `enum[${index}] must be a value or { value, description }`;
132
+ const optionValue = isRecord(option) ? option.value : option;
133
+ if (!valueChecks[type]?.(optionValue)) return `enum[${index}] ${JSON.stringify(optionValue)} isn't of type "${type}"`;
134
+ values.push(optionValue);
135
+ }
136
+ return values.includes(value) ? void 0 : `default ${JSON.stringify(value)} isn't in the enum`;
137
+ }
138
+ //#endregion
139
+ //#region src/config.ts
140
+ /** The previewed file is untrusted input; nothing on the page needs the network. */
141
+ const defaultCsp = {
142
+ "default-src": ["'none'"],
143
+ "script-src": ["'self'"],
144
+ "style-src": ["'self'", "'unsafe-inline'"],
145
+ "img-src": [
146
+ "'self'",
147
+ "data:",
148
+ "blob:"
149
+ ],
150
+ "font-src": ["'self'", "data:"],
151
+ "media-src": [
152
+ "'self'",
153
+ "data:",
154
+ "blob:"
155
+ ]
156
+ };
157
+ const defineConfig = (config) => config;
158
+ const serializeCsp = (csp) => Object.entries(csp).map(([directive, sources = []]) => [directive, ...sources].join(" ")).join("; ");
159
+ /** `csp` with `sources` appended to `directive`. */
160
+ const addSources = (csp, directive, ...sources) => ({
161
+ ...csp,
162
+ [directive]: [...csp[directive] ?? [], ...sources]
163
+ });
164
+ /**
165
+ * Built-in frameworks: their dev and build commands, and the default `outDir`
166
+ * of a static, client-side build. Each takes `{ outDir }` to override it.
167
+ */
168
+ const frameworkBuilds = {
169
+ analog: [
170
+ ["vite", "dev"],
171
+ ["vite", "build"],
172
+ "dist"
173
+ ],
174
+ astro: [
175
+ ["astro", "dev"],
176
+ ["astro", "build"],
177
+ "dist"
178
+ ],
179
+ nuxt: [
180
+ ["nuxt", "dev"],
181
+ ["nuxt", "generate"],
182
+ ".output/public"
183
+ ],
184
+ "solid-start": [
185
+ ["vite", "dev"],
186
+ ["vite", "build"],
187
+ ".output/public"
188
+ ],
189
+ sveltekit: [
190
+ ["vite", "dev"],
191
+ ["vite", "build"],
192
+ "build"
193
+ ]
194
+ };
195
+ function builtinFramework(name, options) {
196
+ const builtin = frameworkBuilds[name];
197
+ if (!builtin) return;
198
+ const [dev, command, outDir] = builtin;
199
+ return {
200
+ name,
201
+ build: {
202
+ dev,
203
+ command,
204
+ outDir: options?.outDir ?? outDir
205
+ }
206
+ };
207
+ }
208
+ async function resolveIntegration(ref, root) {
209
+ if (typeof ref === "object" && !Array.isArray(ref)) return ref;
210
+ const [name, options] = typeof ref === "string" ? [ref] : ref;
211
+ const builtin = builtinFramework(name, options);
212
+ if (builtin) return builtin;
213
+ const exported = await createJiti(join(root, "quicklook.config")).import(name, { default: true });
214
+ return typeof exported === "function" ? exported(options) : exported;
215
+ }
216
+ async function loadConfig$1(root = process.cwd()) {
217
+ const { config } = await loadConfig({
218
+ name: "quicklook",
219
+ cwd: root
220
+ });
221
+ if (!config?.name) throw new Error(`No quicklook.config.* with a name found in ${root}`);
222
+ validateSettings(config.settings ?? {});
223
+ const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
224
+ const framework = await resolveIntegration(frameworkRef, root);
225
+ if (!framework.build) throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
226
+ const integrations = [framework];
227
+ for (const ref of refs) integrations.push(await resolveIntegration(ref, root));
228
+ const resolved = {
229
+ macOS: "14.0",
230
+ maxFileSize: 1e7,
231
+ public: {},
232
+ settings: {},
233
+ ...rest,
234
+ csp: {
235
+ ...defaultCsp,
236
+ ...config.csp
237
+ },
238
+ root,
239
+ framework: {
240
+ ...framework,
241
+ build: framework.build
242
+ },
243
+ integrations
244
+ };
245
+ for (const integration of integrations) await integration.hooks?.["config:resolved"]?.(resolved);
246
+ return resolved;
247
+ }
248
+ //#endregion
249
+ export { serializeCsp as a, loadConfig$1 as i, defaultCsp as n, defineConfig as r, addSources as t };
package/dist/config.d.mts CHANGED
@@ -13,6 +13,41 @@ export interface DeclaredType {
13
13
  export type Csp = Partial<Record<string, string[]>>;
14
14
  /** The xcodegen project spec, written as project.yml. */
15
15
  export type XcodeSpec = Record<string, any>;
16
+ /** An `enum` entry: a plain value, or one with a label for the picker. */
17
+ export type EnumEntry<T> = T | {
18
+ value: T;
19
+ description?: string;
20
+ };
21
+ interface BaseSetting {
22
+ /** Defaults to the key, turned into words (`fontSize` → "Font Size"). */
23
+ title?: string;
24
+ /** Inline Markdown. */
25
+ description?: string;
26
+ order?: number;
27
+ }
28
+ /**
29
+ * A user-editable setting. The type decides the control the app shows; the
30
+ * page gets the resolved values from `settings()`.
31
+ */
32
+ export type Setting = (BaseSetting & {
33
+ type: 'boolean';
34
+ default: boolean;
35
+ }) | (BaseSetting & {
36
+ type: 'string';
37
+ default: string;
38
+ enum?: EnumEntry<string>[];
39
+ radio?: boolean;
40
+ }) | (BaseSetting & {
41
+ type: 'integer' | 'number';
42
+ default: number;
43
+ enum?: EnumEntry<number>[];
44
+ radio?: boolean;
45
+ minimum?: number;
46
+ maximum?: number;
47
+ }) | (BaseSetting & {
48
+ type: 'object';
49
+ properties: Record<string, Setting>;
50
+ });
16
51
  export interface Integration {
17
52
  name: string;
18
53
  /** Set by frameworks only: how to run and build the page, and where it lands. */
@@ -55,10 +90,12 @@ export interface QuickLookConfig {
55
90
  integrations?: IntegrationRef[];
56
91
  /** Directives given here replace the defaults of the same name. */
57
92
  csp?: Csp;
58
- /** Larger files get QuickLook's generic preview. Defaults to 10 MB. */
93
+ /** Larger files get Quick Look's generic preview. Defaults to 10 MB. */
59
94
  maxFileSize?: number;
60
95
  /** Exposed to the page as `config.public` from `virtual:quicklook`. */
61
96
  public?: Record<string, unknown>;
97
+ /** Settings the user edits in the app, read on the page with `settings()`. */
98
+ settings?: Record<string, Setting>;
62
99
  }
63
100
  /** What the page sees as `config` from `virtual:quicklook`. */
64
101
  export type PageConfig = Pick<ResolvedConfig, 'name' | 'bundleId' | 'types' | 'public'>;
@@ -77,4 +114,5 @@ export declare const serializeCsp: (csp: Csp) => string;
77
114
  /** `csp` with `sources` appended to `directive`. */
78
115
  export declare const addSources: (csp: Csp, directive: string, ...sources: string[]) => Csp;
79
116
  export declare function loadConfig(root?: string): Promise<ResolvedConfig>;
80
- //#endregion
117
+ //#endregion
118
+ export {};
package/dist/config.mjs CHANGED
@@ -1 +1 @@
1
- export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-BQ-9qYNV.mjs";
1
+ export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-BVPHnCWr.mjs";
@@ -1,5 +1,5 @@
1
1
  //#region src/runtime.d.ts
2
- /** The file QuickLook asked to preview. */
2
+ /** The file Quick Look asked to preview. */
3
3
  export interface QuickLookFile {
4
4
  name: string;
5
5
  /** Lowercase, without the dot. */
@@ -13,10 +13,12 @@ declare global {
13
13
  name: string;
14
14
  extension: string;
15
15
  base64: string;
16
+ /** Present when the config declares `settings`. */
17
+ settings?: Record<string, unknown>;
16
18
  };
17
19
  }
18
20
  }
19
- /** The previewed file inside QuickLook; `undefined` in the browser. */
21
+ /** The previewed file inside Quick Look; `undefined` in the browser. */
20
22
  export declare function nativeFile(): QuickLookFile | undefined;
21
23
  /** The same shape from a file picked or dropped in the browser, for the dev server. */
22
24
  export declare function browserFile(file: File): Promise<QuickLookFile>;
package/dist/runtime.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region src/runtime.ts
2
- /** The previewed file inside QuickLook; `undefined` in the browser. */
2
+ /** The previewed file inside Quick Look; `undefined` in the browser. */
3
3
  function nativeFile() {
4
4
  const injected = window.quicklook;
5
5
  if (!injected) return;
package/dist/vite.mjs CHANGED
@@ -1,6 +1,10 @@
1
- import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BQ-9qYNV.mjs";
1
+ import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BVPHnCWr.mjs";
2
2
  //#region src/vite.ts
3
3
  const virtualId = "virtual:quicklook";
4
+ /** The schema's defaults as nested objects, the shape the extension injects. */
5
+ function settingDefaults(settings) {
6
+ return Object.fromEntries(Object.entries(settings).map(([key, setting]) => [key, setting.type === "object" ? settingDefaults(setting.properties) : setting.default]));
7
+ }
4
8
  /**
5
9
  * Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
6
10
  * the dev server report what the extension's CSP would block.
@@ -15,7 +19,9 @@ async function quicklook() {
15
19
  public: config.public
16
20
  };
17
21
  const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
18
- export { browserFile, nativeFile } from 'quicklook-kit/runtime';`;
22
+ export { browserFile, nativeFile } from 'quicklook-kit/runtime';
23
+ // The injected values in Quick Look, the defaults in the dev server and when prerendering.
24
+ export const settings = () => globalThis.window?.quicklook?.settings ?? ${JSON.stringify(settingDefaults(config.settings))};`;
19
25
  const devCsp = serializeCsp(addSources(addSources(config.csp, "script-src", "'unsafe-inline'"), "connect-src", "'self'", "ws:"));
20
26
  return [{
21
27
  name: "quicklook",
@@ -1,21 +1,386 @@
1
1
  import SwiftUI
2
+ import UniformTypeIdentifiers
2
3
 
3
- /// QuickLook extensions have to ship inside an app, and macOS registers them
4
- /// when that app is first launched. This is all the app does.
4
+ /// Quick Look extensions have to ship inside an app, and macOS registers them
5
+ /// when that app is first launched. Beyond that, the app only lets the user
6
+ /// enable or disable the extension.
5
7
  @main
6
8
  struct HostApp: App {
7
9
  private let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
8
10
 
11
+ private let specs = SettingSpec.load()
12
+
9
13
  var body: some Scene {
10
14
  Window(name, id: "main") {
15
+ if specs.isEmpty {
16
+ ContentView(name: name)
17
+ .padding([.horizontal, .top], 32)
18
+ // Matches the gap above the footer.
19
+ .padding(.bottom, 16)
20
+ .fixedSize()
21
+ } else {
22
+ TabView {
23
+ ContentView(name: name)
24
+ .padding([.horizontal, .top], 32)
25
+ // Matches the gap above the footer.
26
+ .padding(.bottom, 16)
27
+ .tabItem { Text("General") }
28
+ VStack(spacing: 16) {
29
+ ScrollView {
30
+ VStack(alignment: .leading, spacing: 20) {
31
+ ForEach(specs, id: \.key) { SettingRow(spec: $0) }
32
+ }
33
+ .frame(width: 360, alignment: .leading)
34
+ }
35
+ // Caps the window height; longer forms scroll.
36
+ .frame(maxHeight: 420)
37
+ Footer()
38
+ }
39
+ .frame(width: 360)
40
+ .padding([.horizontal, .top], 32)
41
+ // Matches the gap above the footer.
42
+ .padding(.bottom, 16)
43
+ .tabItem { Text("Settings") }
44
+ }
45
+ .padding([.horizontal, .top], 20)
46
+ .fixedSize()
47
+ }
48
+ }
49
+ .windowResizability(.contentSize)
50
+ }
51
+ }
52
+
53
+ /// Shown at the bottom of every tab.
54
+ private struct Footer: View {
55
+ var body: some View {
56
+ Divider()
57
+ HStack(spacing: 3) {
58
+ Text("Made with").foregroundStyle(.tertiary)
59
+ Link("quicklook-kit", destination: URL(string: "https://github.com/idleberg/quicklook-kit")!)
60
+ .foregroundStyle(.secondary)
61
+ // A separate view, so only the link gets the pointer.
62
+ .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() }
63
+ }
64
+ .font(.caption)
65
+ }
66
+ }
67
+
68
+ /// One entry of the settings form, in a single column: title, description, control.
69
+ /// Values live in `UserDefaults.standard` under the dotted key, where the extension reads them.
70
+ private struct SettingRow: View {
71
+ let spec: SettingSpec
72
+
73
+ var body: some View {
74
+ VStack(alignment: .leading, spacing: 6) {
75
+ if spec.type == "boolean" {
76
+ CheckboxSetting(spec: spec)
77
+ SettingDescription(spec: spec)
78
+ } else {
79
+ Text(spec.title).font(spec.type == "object" ? .headline : .body).bold()
80
+ SettingDescription(spec: spec)
81
+ if spec.type == "object" {
82
+ VStack(alignment: .leading, spacing: 16) {
83
+ ForEach(spec.children, id: \.key) { SettingRow(spec: $0) }
84
+ }
85
+ .padding(.leading, 16)
86
+ .padding(.top, 6)
87
+ } else if !spec.options.isEmpty {
88
+ PickerSetting(spec: spec)
89
+ } else {
90
+ FieldSetting(spec: spec)
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ private struct SettingDescription: View {
98
+ let spec: SettingSpec
99
+
100
+ var body: some View {
101
+ if let description = spec.description {
102
+ let options = AttributedString.MarkdownParsingOptions(
103
+ interpretedSyntax: .inlineOnlyPreservingWhitespace)
104
+ Text(
105
+ (try? AttributedString(markdown: description, options: options))
106
+ ?? AttributedString(description)
107
+ )
108
+ .foregroundStyle(.secondary)
109
+ .fixedSize(horizontal: false, vertical: true)
110
+ }
111
+ }
112
+ }
113
+
114
+ private struct CheckboxSetting: View {
115
+ let spec: SettingSpec
116
+ @State private var isOn: Bool
117
+
118
+ init(spec: SettingSpec) {
119
+ self.spec = spec
120
+ _isOn = State(initialValue: (storedValue(spec) ?? spec.defaultValue) as? Bool ?? false)
121
+ }
122
+
123
+ var body: some View {
124
+ Toggle(isOn: $isOn) { Text(spec.title).bold() }
125
+ .toggleStyle(.checkbox)
126
+ .onChange(of: isOn) { save(isOn, for: spec) }
127
+ }
128
+ }
129
+
130
+ private struct PickerSetting: View {
131
+ let spec: SettingSpec
132
+ @State private var selection: Int
133
+
134
+ init(spec: SettingSpec) {
135
+ self.spec = spec
136
+ let current = storedValue(spec) ?? spec.defaultValue
137
+ _selection = State(
138
+ initialValue: spec.options.firstIndex { option in
139
+ current.map { same(option.value, $0) } ?? false
140
+ } ?? 0)
141
+ }
142
+
143
+ var body: some View {
144
+ let picker = Picker(spec.title, selection: $selection) {
145
+ ForEach(spec.options.indices, id: \.self) { Text(spec.options[$0].label).tag($0) }
146
+ }
147
+ .labelsHidden()
148
+ .fixedSize()
149
+ .onChange(of: selection) { save(spec.options[selection].value, for: spec) }
150
+
151
+ if spec.radio {
152
+ picker.pickerStyle(.radioGroup)
153
+ } else {
154
+ picker.pickerStyle(.menu)
155
+ }
156
+ }
157
+ }
158
+
159
+ /// Saves on Return or focus loss. Empty removes the stored value, so the default
160
+ /// (shown as placeholder) applies again.
161
+ private struct FieldSetting: View {
162
+ let spec: SettingSpec
163
+ @State private var text: String
164
+ @FocusState private var isFocused: Bool
165
+
166
+ init(spec: SettingSpec) {
167
+ self.spec = spec
168
+ _text = State(initialValue: storedValue(spec).map { "\($0)" } ?? "")
169
+ }
170
+
171
+ var body: some View {
172
+ TextField(spec.defaultValue.map { String(describing: $0) } ?? "", text: $text)
173
+ .focused($isFocused)
174
+ .onSubmit(commit)
175
+ .onChange(of: isFocused) { if !isFocused { commit() } }
176
+ }
177
+
178
+ private func commit() {
179
+ let input = spec.type == "string" ? text : text.trimmingCharacters(in: .whitespaces)
180
+ if input.isEmpty {
181
+ text = ""
182
+ save(nil, for: spec)
183
+ return
184
+ }
185
+
186
+ let clamp = { (raw: Double) in
187
+ min(max(raw, spec.minimum ?? -.infinity), spec.maximum ?? .infinity)
188
+ }
189
+ var value: Any?
190
+ switch spec.type {
191
+ // Int() rejects fractions, so "2.5" never saves for an integer.
192
+ case "integer":
193
+ value = Int(input).map { clamp(Double($0)) == Double($0) ? $0 : Int(clamp(Double($0))) }
194
+ case "number": value = Double(input).map(clamp)
195
+ default: value = input
196
+ }
197
+
198
+ // Invalid input isn't saved; the field goes back to what's stored.
199
+ guard let value, spec.isValid(value) else {
200
+ text = storedValue(spec).map { "\($0)" } ?? ""
201
+ return
202
+ }
203
+ text = "\(value)"
204
+ save(value, for: spec)
205
+ }
206
+ }
207
+
208
+ /// The stored value, or nil when there is none or it no longer fits the schema.
209
+ private func storedValue(_ spec: SettingSpec) -> Any? {
210
+ guard let value = UserDefaults.standard.object(forKey: spec.key), spec.isValid(value) else {
211
+ return nil
212
+ }
213
+ return value
214
+ }
215
+
216
+ /// Compares through NSObject, so an Int option matches the NSNumber UserDefaults returns,
217
+ /// and 2 matches 2.0.
218
+ private func same(_ lhs: Any, _ rhs: Any) -> Bool {
219
+ (lhs as AnyObject).isEqual(rhs as AnyObject)
220
+ }
221
+
222
+ /// Writes or removes (nil) the value, then resets Quick Look's cache so the next
223
+ /// preview picks it up. Open previews don't update.
224
+ private func save(_ value: Any?, for spec: SettingSpec) {
225
+ let defaults = UserDefaults.standard
226
+ let old = defaults.object(forKey: spec.key)
227
+ if let value {
228
+ if let old, same(old, value) { return }
229
+ defaults.set(value, forKey: spec.key)
230
+ } else {
231
+ if old == nil { return }
232
+ defaults.removeObject(forKey: spec.key)
233
+ }
234
+
235
+ let process = Process()
236
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/qlmanage")
237
+ process.arguments = ["-r", "cache"]
238
+ process.standardOutput = FileHandle.nullDevice
239
+ try? process.run()
240
+ }
241
+
242
+ private struct ContentView: View {
243
+ let name: String
244
+
245
+ // Matches the extension's PRODUCT_BUNDLE_IDENTIFIER in cli.ts.
246
+ private let extensionId = "\(Bundle.main.bundleIdentifier ?? "").Extension"
247
+
248
+ @State private var isEnabled = true
249
+ @State private var status: String?
250
+ @State private var conflicts: [String] = []
251
+
252
+ var body: some View {
253
+ VStack(spacing: 16) {
11
254
  VStack(spacing: 8) {
12
255
  Text(name).font(.headline)
13
- Text("The QuickLook preview is installed. You can close this window.")
256
+ Text("Previews supported files when you press Space in Finder.")
14
257
  .foregroundStyle(.secondary)
258
+ .multilineTextAlignment(.center)
15
259
  }
16
- .padding(32)
17
- .fixedSize()
260
+
261
+ Button(isEnabled ? "Disable Preview" : "Enable Preview", action: toggle)
262
+
263
+ if let status {
264
+ Text(status).font(.caption).foregroundStyle(.secondary)
265
+ }
266
+
267
+ if isEnabled && !conflicts.isEmpty {
268
+ VStack(alignment: .leading, spacing: 8) {
269
+ Text("⚠️ \(conflicts.count == 1 ? "There is a conflict:" : "There are conflicts:")")
270
+ .bold()
271
+ ForEach(conflicts, id: \.self) { conflict in
272
+ Label {
273
+ Text(conflict)
274
+ } icon: {
275
+ Text("•")
276
+ }
277
+ .fixedSize(horizontal: false, vertical: true)
278
+ }
279
+ }
280
+ .font(.callout)
281
+ .frame(maxWidth: .infinity, alignment: .leading)
282
+ }
283
+
284
+ Footer()
18
285
  }
19
- .windowResizability(.contentSize)
286
+ // Fixed so the window only grows in height as text comes and goes.
287
+ .frame(width: 360)
288
+ .onAppear(perform: refresh)
289
+ }
290
+
291
+ private func refresh() {
292
+ isEnabled = currentlyEnabled()
293
+ // The same app installed twice yields the same line twice; ForEach needs unique ids.
294
+ var seen = Set<String>()
295
+ conflicts = findConflicts().filter { seen.insert($0).inserted }
296
+ }
297
+
298
+ /// Other apps that would win over this preview for some of its file types.
299
+ /// ponytail: how Quick Look picks between conflicting extensions isn't documented, so
300
+ /// this is a best guess. Any overlap is reported, even if we'd happen to win.
301
+ private func findConflicts() -> [String] {
302
+ var conflicts: [String] = []
303
+
304
+ // Same file extension, two types: LaunchServices resolves the extension to only one.
305
+ // UTIs are case-insensitive, and LaunchServices returns them lowercased.
306
+ let declarations = Bundle.main.object(forInfoDictionaryKey: "UTExportedTypeDeclarations")
307
+ for declaration in declarations as? [[String: Any]] ?? [] {
308
+ guard let id = declaration["UTTypeIdentifier"] as? String,
309
+ let tags = declaration["UTTypeTagSpecification"] as? [String: Any],
310
+ let extensions = tags["public.filename-extension"] as? [String]
311
+ else { continue }
312
+ for ext in extensions {
313
+ guard let other = UTType(filenameExtension: ext)?.identifier,
314
+ other.lowercased() != id.lowercased()
315
+ else { continue }
316
+ conflicts.append("“.\(ext)” files are claimed by another app’s type “\(other)”.")
317
+ }
318
+ }
319
+
320
+ // Same type, two extensions.
321
+ let ourTypes = supportedTypes(Bundle.main.builtInPlugInsURL?.appendingPathComponent(appexName))
322
+ guard let output = pluginkit("-m", "-v", "-p", "com.apple.quicklook.preview") else {
323
+ return conflicts
324
+ }
325
+ for line in output.split(separator: "\n") {
326
+ guard let path = line.split(separator: "\t").last.map(String.init),
327
+ !line.trimmingCharacters(in: .whitespaces).hasPrefix("-"),
328
+ !line.contains(" \(extensionId)("),
329
+ !path.hasPrefix("/System/")
330
+ else { continue }
331
+ let theirTypes = supportedTypes(URL(fileURLWithPath: path))
332
+ let overlaps = ourTypes.contains { ours in theirTypes.contains { ours.conforms(to: $0) } }
333
+ guard overlaps else { continue }
334
+ let app = path.split(separator: "/").first { $0.hasSuffix(".app") }?.dropLast(4)
335
+ conflicts.append(
336
+ "“\(app ?? Substring(path))” also previews these files. Turn it off in System Settings → "
337
+ + "Login Items & Extensions → Quick Look.")
338
+ }
339
+ return conflicts
340
+ }
341
+
342
+ private var appexName: String {
343
+ let plugins = Bundle.main.builtInPlugInsPath ?? ""
344
+ let items = (try? FileManager.default.contentsOfDirectory(atPath: plugins)) ?? []
345
+ return items.first { $0.hasSuffix(".appex") } ?? ""
346
+ }
347
+
348
+ private func supportedTypes(_ appex: URL?) -> [UTType] {
349
+ guard let appex, let info = Bundle(url: appex)?.infoDictionary,
350
+ let attributes = (info["NSExtension"] as? [String: Any])?["NSExtensionAttributes"]
351
+ as? [String: Any],
352
+ let ids = attributes["QLSupportedContentTypes"] as? [String]
353
+ else { return [] }
354
+ return ids.compactMap(UTType.init)
355
+ }
356
+
357
+ /// `pluginkit -mv` prefixes a line with `-` when the extension is ignored,
358
+ /// `+` when explicitly enabled, and nothing when enabled by default.
359
+ private func currentlyEnabled() -> Bool {
360
+ guard let output = pluginkit("-m", "-v", "-i", extensionId) else { return true }
361
+ return !output.trimmingCharacters(in: .whitespaces).hasPrefix("-")
362
+ }
363
+
364
+ private func toggle() {
365
+ guard pluginkit("-e", isEnabled ? "ignore" : "use", "-i", extensionId) != nil else {
366
+ status = "Couldn't change the preview. Try again from System Settings."
367
+ return
368
+ }
369
+ refresh()
370
+ status = isEnabled ? "Preview enabled." : "Preview disabled."
371
+ }
372
+
373
+ /// Returns stdout, or nil if pluginkit failed to run or exited non-zero.
374
+ private func pluginkit(_ args: String...) -> String? {
375
+ let process = Process()
376
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/pluginkit")
377
+ process.arguments = args
378
+ let pipe = Pipe()
379
+ process.standardOutput = pipe
380
+ guard (try? process.run()) != nil else { return nil }
381
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
382
+ process.waitUntilExit()
383
+ guard process.terminationStatus == 0 else { return nil }
384
+ return String(data: data, encoding: .utf8)
20
385
  }
21
386
  }
@@ -6,7 +6,7 @@ import WebKit
6
6
  /// file as `window.quicklook`. See packages/quicklook-kit/src/runtime.ts.
7
7
  @objc(PreviewViewController)
8
8
  final class PreviewViewController: NSViewController, QLPreviewingController {
9
- /// Larger files throw, which makes QuickLook fall back to its generic preview
9
+ /// Larger files throw, which makes Quick Look fall back to its generic preview
10
10
  /// instead of stalling while megabytes of base64 cross into the page.
11
11
  private static let maxFileSize =
12
12
  Bundle.main.object(forInfoDictionaryKey: "QLMaxFileSize") as? Int ?? 10_000_000
@@ -29,11 +29,18 @@ final class PreviewViewController: NSViewController, QLPreviewingController {
29
29
  let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
30
30
  guard size <= Self.maxFileSize else { throw CocoaError(.fileReadTooLarge) }
31
31
 
32
- let file = [
32
+ var file: [String: Any] = [
33
33
  "name": url.lastPathComponent,
34
34
  "extension": url.pathExtension.lowercased(),
35
35
  "base64": try Data(contentsOf: url).base64EncodedString(),
36
36
  ]
37
+ // The host app saves settings to its own defaults; the extension's bundle id
38
+ // is the app's plus `.Extension`, and its entitlements allow reading them.
39
+ let specs = SettingSpec.load()
40
+ let suite = Bundle.main.bundleIdentifier.map { String($0.dropLast(".Extension".count)) }
41
+ if !specs.isEmpty, let suite, let defaults = UserDefaults(suiteName: suite) {
42
+ file["settings"] = resolveSettings(specs, from: defaults)
43
+ }
37
44
  // JSON is a valid JavaScript expression, so this needs no escaping of its own.
38
45
  let json = String(decoding: try JSONSerialization.data(withJSONObject: file), as: UTF8.self)
39
46
  let scripts = webView.configuration.userContentController
@@ -43,7 +50,7 @@ final class PreviewViewController: NSViewController, QLPreviewingController {
43
50
  source: "window.quicklook = \(json);", injectionTime: .atDocumentStart,
44
51
  forMainFrameOnly: true))
45
52
 
46
- // QuickLook displays the view as soon as this returns, so wait for the
53
+ // Quick Look displays the view as soon as this returns, so wait for the
47
54
  // page to actually finish loading — otherwise larger files show blank.
48
55
  let page = URLRequest(url: URL(string: "quicklook://app/")!)
49
56
  try await loadWaiter.load { webView.load(page) }
@@ -117,7 +124,7 @@ private final class LoadWaiter: NSObject, WKNavigationDelegate {
117
124
  }
118
125
 
119
126
  /// A crashed content process fires no navigation callback at all — without
120
- /// this the await never returns and QuickLook spins forever.
127
+ /// this the await never returns and Quick Look spins forever.
121
128
  func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
122
129
  finish(.failure(CocoaError(.featureUnsupported)))
123
130
  }
@@ -0,0 +1,129 @@
1
+ import Foundation
2
+
3
+ /// One entry of the `QLSettings` schema the build writes into Info.plist.
4
+ /// Compiled into both the app, which edits the values, and the extension,
5
+ /// which hands them to the page. See packages/quicklook-kit/src/config.ts.
6
+ struct SettingSpec {
7
+ let key: String
8
+ let type: String
9
+ let title: String
10
+ let description: String?
11
+ let defaultValue: Any?
12
+ let options: [(value: Any, label: String)]
13
+ let radio: Bool
14
+ let minimum: Double?
15
+ let maximum: Double?
16
+ let children: [SettingSpec]
17
+
18
+ private init(_ entry: [String: Any], prefix: String) {
19
+ let name = entry["key"] as? String ?? ""
20
+ let type = entry["type"] as? String ?? ""
21
+ key = prefix + name
22
+ self.type = type
23
+ title = entry["title"] as? String ?? words(name)
24
+ description = entry["description"] as? String
25
+ radio = bool(entry["radio"] as Any) ?? false
26
+ minimum = number(entry["minimum"] as Any)?.doubleValue
27
+ maximum = number(entry["maximum"] as Any)?.doubleValue
28
+ children = Self.sorted(entry["properties"] as? [[String: Any]] ?? [], prefix: key + ".")
29
+ defaultValue = entry["default"].flatMap { Self.cast($0, as: type) }
30
+ options = (entry["enum"] as? [Any] ?? []).compactMap { option in
31
+ let object = option as? [String: Any]
32
+ guard let value = Self.cast(object?["value"] ?? option, as: type) else { return nil }
33
+ return (value, object?["description"] as? String ?? "\(value)")
34
+ }
35
+ }
36
+
37
+ /// Whether a stored value fits this entry: right type, in `options` if any, within bounds, integers whole.
38
+ func isValid(_ value: Any) -> Bool { validated(value) != nil }
39
+
40
+ /// `value` as this entry's Swift type if it fits, so a stored 3.0 comes back as integer 3.
41
+ func validated(_ value: Any) -> Any? {
42
+ guard let cast = Self.cast(value, as: type) else { return nil }
43
+ if !options.isEmpty, !options.contains(where: { ($0.value as AnyObject).isEqual(cast) }) {
44
+ return nil
45
+ }
46
+ if let num = number(cast)?.doubleValue {
47
+ if let minimum, num < minimum { return nil }
48
+ if let maximum, num > maximum { return nil }
49
+ }
50
+ return cast
51
+ }
52
+
53
+ /// `value` as Bool, String, Int or Double per `type`, or nil if it's the wrong type.
54
+ private static func cast(_ value: Any, as type: String) -> Any? {
55
+ switch type {
56
+ case "boolean": bool(value)
57
+ case "string": value as? String
58
+ case "integer": number(value).flatMap { Int(exactly: $0.doubleValue) }
59
+ case "number": number(value)?.doubleValue
60
+ default: nil
61
+ }
62
+ }
63
+
64
+ /// Top-level entries from the bundle's `QLSettings`, sorted; empty if absent.
65
+ static func load(from bundle: Bundle = .main) -> [SettingSpec] {
66
+ decode(bundle.object(forInfoDictionaryKey: "QLSettings") as? String ?? "[]")
67
+ }
68
+
69
+ /// Entries from a `QLSettings` JSON string, sorted; empty if it doesn't parse.
70
+ static func decode(_ json: String) -> [SettingSpec] {
71
+ let entries = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [[String: Any]]
72
+ return sorted(entries ?? [], prefix: "")
73
+ }
74
+
75
+ /// By `order`, unordered entries last, then in declaration order.
76
+ private static func sorted(_ entries: [[String: Any]], prefix: String) -> [SettingSpec] {
77
+ entries.enumerated()
78
+ .sorted {
79
+ let lhs = number($0.element["order"] as Any)?.doubleValue ?? .infinity
80
+ let rhs = number($1.element["order"] as Any)?.doubleValue ?? .infinity
81
+ return (lhs, $0.offset) < (rhs, $1.offset)
82
+ }
83
+ .map { SettingSpec($0.element, prefix: prefix) }
84
+ }
85
+ }
86
+
87
+ /// Defaults overridden by valid values from `defaults` (read by dotted key), as nested dictionaries.
88
+ func resolveSettings(_ specs: [SettingSpec], from defaults: UserDefaults) -> [String: Any] {
89
+ var values: [String: Any] = [:]
90
+ for spec in specs {
91
+ let name = spec.key.split(separator: ".").last.map(String.init) ?? spec.key
92
+ values[name] =
93
+ spec.type == "object"
94
+ ? resolveSettings(spec.children, from: defaults)
95
+ : defaults.object(forKey: spec.key).flatMap(spec.validated) ?? spec.defaultValue
96
+ }
97
+ return values
98
+ }
99
+
100
+ // UserDefaults and JSONSerialization hand back NSNumber for booleans and numbers
101
+ // alike; only the CFBoolean ones are booleans.
102
+ private func bool(_ value: Any) -> Bool? {
103
+ guard let num = value as? NSNumber, CFGetTypeID(num) == CFBooleanGetTypeID() else { return nil }
104
+ return num.boolValue
105
+ }
106
+
107
+ private func number(_ value: Any) -> NSNumber? {
108
+ guard let num = value as? NSNumber, CFGetTypeID(num) != CFBooleanGetTypeID() else { return nil }
109
+ return num
110
+ }
111
+
112
+ /// "fontSize" → "Font Size"; dashes and underscores become spaces too.
113
+ private func words(_ key: String) -> String {
114
+ var spaced = ""
115
+ var previous: Character?
116
+ for char in key {
117
+ if char == "_" || char == "-" {
118
+ spaced.append(" ")
119
+ } else {
120
+ if char.isUppercase, previous?.isLowercase == true || previous?.isNumber == true {
121
+ spaced.append(" ")
122
+ }
123
+ spaced.append(char)
124
+ }
125
+ previous = char
126
+ }
127
+ return spaced.split(separator: " ").map { $0.prefix(1).uppercased() + $0.dropFirst() }
128
+ .joined(separator: " ")
129
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "quicklook-kit",
3
- "version": "0.4.0",
4
- "description": "Build macOS QuickLook previews with any Vite-based web framework",
3
+ "version": "0.6.0",
4
+ "description": "Build macOS Quick Look previews with any Vite-based web framework",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "quicklook",
@@ -53,13 +53,15 @@
53
53
  "@types/node": "24.19.2",
54
54
  "tsdown": "0.23.1",
55
55
  "typescript": "6.0.3",
56
- "vite": "8.3.4"
56
+ "vite": "8.3.4",
57
+ "vitest": "5.0.5"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "vite": ">=7"
60
61
  },
61
62
  "scripts": {
62
63
  "build": "tsdown",
63
- "check": "tsc --noEmit"
64
+ "check": "tsc --noEmit",
65
+ "test": "vitest run"
64
66
  }
65
67
  }
package/src/virtual.d.ts CHANGED
@@ -3,5 +3,7 @@
3
3
  declare module 'virtual:quicklook' {
4
4
  /** Build-time values from quicklook.config.*. */
5
5
  export const config: import('quicklook-kit').PageConfig;
6
+ /** The user's settings: stored values over the config's defaults, as nested objects. */
7
+ export const settings: () => Record<string, unknown>;
6
8
  export { browserFile, nativeFile, type QuickLookFile } from 'quicklook-kit/runtime';
7
9
  }
@@ -1,113 +0,0 @@
1
- import { join } from "node:path";
2
- import process from "node:process";
3
- import { loadConfig } from "c12";
4
- import { createJiti } from "jiti";
5
- //#region src/config.ts
6
- /** The previewed file is untrusted input; nothing on the page needs the network. */
7
- const defaultCsp = {
8
- "default-src": ["'none'"],
9
- "script-src": ["'self'"],
10
- "style-src": ["'self'", "'unsafe-inline'"],
11
- "img-src": [
12
- "'self'",
13
- "data:",
14
- "blob:"
15
- ],
16
- "font-src": ["'self'", "data:"],
17
- "media-src": [
18
- "'self'",
19
- "data:",
20
- "blob:"
21
- ]
22
- };
23
- const defineConfig = (config) => config;
24
- const serializeCsp = (csp) => Object.entries(csp).map(([directive, sources = []]) => [directive, ...sources].join(" ")).join("; ");
25
- /** `csp` with `sources` appended to `directive`. */
26
- const addSources = (csp, directive, ...sources) => ({
27
- ...csp,
28
- [directive]: [...csp[directive] ?? [], ...sources]
29
- });
30
- /**
31
- * Built-in frameworks: their dev and build commands, and the default `outDir`
32
- * of a static, client-side build. Each takes `{ outDir }` to override it.
33
- */
34
- const frameworkBuilds = {
35
- analog: [
36
- ["vite", "dev"],
37
- ["vite", "build"],
38
- "dist"
39
- ],
40
- astro: [
41
- ["astro", "dev"],
42
- ["astro", "build"],
43
- "dist"
44
- ],
45
- nuxt: [
46
- ["nuxt", "dev"],
47
- ["nuxt", "generate"],
48
- ".output/public"
49
- ],
50
- "solid-start": [
51
- ["vite", "dev"],
52
- ["vite", "build"],
53
- ".output/public"
54
- ],
55
- sveltekit: [
56
- ["vite", "dev"],
57
- ["vite", "build"],
58
- "build"
59
- ]
60
- };
61
- function builtinFramework(name, options) {
62
- const builtin = frameworkBuilds[name];
63
- if (!builtin) return;
64
- const [dev, command, outDir] = builtin;
65
- return {
66
- name,
67
- build: {
68
- dev,
69
- command,
70
- outDir: options?.outDir ?? outDir
71
- }
72
- };
73
- }
74
- async function resolveIntegration(ref, root) {
75
- if (typeof ref === "object" && !Array.isArray(ref)) return ref;
76
- const [name, options] = typeof ref === "string" ? [ref] : ref;
77
- const builtin = builtinFramework(name, options);
78
- if (builtin) return builtin;
79
- const exported = await createJiti(join(root, "quicklook.config")).import(name, { default: true });
80
- return typeof exported === "function" ? exported(options) : exported;
81
- }
82
- async function loadConfig$1(root = process.cwd()) {
83
- const { config } = await loadConfig({
84
- name: "quicklook",
85
- cwd: root
86
- });
87
- if (!config?.name) throw new Error(`No quicklook.config.* with a name found in ${root}`);
88
- const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
89
- const framework = await resolveIntegration(frameworkRef, root);
90
- if (!framework.build) throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
91
- const integrations = [framework];
92
- for (const ref of refs) integrations.push(await resolveIntegration(ref, root));
93
- const resolved = {
94
- macOS: "14.0",
95
- maxFileSize: 1e7,
96
- public: {},
97
- ...rest,
98
- csp: {
99
- ...defaultCsp,
100
- ...config.csp
101
- },
102
- root,
103
- framework: {
104
- ...framework,
105
- build: framework.build
106
- },
107
- integrations
108
- };
109
- for (const integration of integrations) await integration.hooks?.["config:resolved"]?.(resolved);
110
- return resolved;
111
- }
112
- //#endregion
113
- export { serializeCsp as a, loadConfig$1 as i, defaultCsp as n, defineConfig as r, addSources as t };