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 +4 -0
- package/README.markdown +53 -7
- package/dist/cli.mjs +39 -15
- package/dist/config-BVPHnCWr.mjs +249 -0
- package/dist/config.d.mts +40 -2
- package/dist/config.mjs +1 -1
- package/dist/runtime.d.mts +4 -2
- package/dist/runtime.mjs +1 -1
- package/dist/vite.mjs +8 -2
- package/native/App/HostApp.swift +371 -6
- package/native/Extension/PreviewViewController.swift +11 -4
- package/native/Shared/Settings.swift +129 -0
- package/package.json +6 -4
- package/src/virtual.d.ts +2 -0
- package/dist/config-BQ-9qYNV.mjs +0 -113
package/LICENSE
CHANGED
package/README.markdown
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.org/package/quicklook-kit)
|
|
5
5
|
[](https://github.com/idleberg/quicklook-kit/actions)
|
|
6
6
|
|
|
7
|
-
> Build macOS
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
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-
|
|
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. */
|
|
@@ -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
|
|
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-
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/runtime.d.ts
|
|
2
|
-
/** The file
|
|
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
|
|
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
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
|
@@ -1,21 +1,386 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
+
import UniformTypeIdentifiers
|
|
2
3
|
|
|
3
|
-
///
|
|
4
|
-
/// when that app is first launched.
|
|
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("
|
|
256
|
+
Text("Previews supported files when you press Space in Finder.")
|
|
14
257
|
.foregroundStyle(.secondary)
|
|
258
|
+
.multilineTextAlignment(.center)
|
|
15
259
|
}
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
"description": "Build macOS
|
|
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
|
}
|
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 };
|