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 +42 -0
- package/dist/cli.mjs +39 -15
- package/dist/config-BVPHnCWr.mjs +249 -0
- package/dist/config.d.mts +39 -1
- package/dist/config.mjs +1 -1
- package/dist/runtime.d.mts +2 -0
- package/dist/vite.mjs +8 -2
- package/native/App/HostApp.swift +224 -8
- package/native/Extension/PreviewViewController.swift +8 -1
- package/native/Shared/Settings.swift +129 -0
- package/package.json +5 -3
- package/src/virtual.d.ts +2 -0
- package/dist/config-BQ-9qYNV.mjs +0 -113
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-
|
|
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: [
|
|
91
|
-
|
|
92
|
-
|
|
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: [
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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-
|
|
1
|
+
export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-BVPHnCWr.mjs";
|
package/dist/runtime.d.mts
CHANGED
package/dist/vite.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-
|
|
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",
|
package/native/App/HostApp.swift
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/config-BQ-9qYNV.mjs
DELETED
|
@@ -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 };
|