quicklook-kit 0.5.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/README.markdown CHANGED
@@ -64,6 +64,7 @@ The config can also be `quicklook.config.js`, `.json`, `.yaml` or `.toml`, or a
64
64
  | `csp` | locked down | Directives here replace the defaults of the same name |
65
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
 
@@ -150,6 +151,47 @@ Add `.quicklook` to your `.gitignore`.
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`.
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. */
@@ -59,6 +94,8 @@ export interface QuickLookConfig {
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";
@@ -13,6 +13,8 @@ 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
  }
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",
@@ -8,16 +8,237 @@ import UniformTypeIdentifiers
8
8
  struct HostApp: App {
9
9
  private let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
10
10
 
11
+ private let specs = SettingSpec.load()
12
+
11
13
  var body: some Scene {
12
14
  Window(name, id: "main") {
13
- ContentView(name: name)
14
- .padding(32)
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)
15
46
  .fixedSize()
47
+ }
16
48
  }
17
49
  .windowResizability(.contentSize)
18
50
  }
19
51
  }
20
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
+
21
242
  private struct ContentView: View {
22
243
  let name: String
23
244
 
@@ -60,12 +281,7 @@ private struct ContentView: View {
60
281
  .frame(maxWidth: .infinity, alignment: .leading)
61
282
  }
62
283
 
63
- Divider()
64
-
65
- Text("Made with [quicklook-kit](https://github.com/idleberg/quicklook-kit)")
66
- .font(.caption)
67
- .foregroundStyle(.tertiary)
68
- .tint(.secondary)
284
+ Footer()
69
285
  }
70
286
  // Fixed so the window only grows in height as text comes and goes.
71
287
  .frame(width: 360)
@@ -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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "quicklook-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Build macOS Quick Look previews with any Vite-based web framework",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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 };