quicklook-kit 0.2.0 → 0.3.1

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 ADDED
@@ -0,0 +1,23 @@
1
+ Permission is hereby granted, free of charge, to any
2
+ person obtaining a copy of this software and associated
3
+ documentation files (the "Software"), to deal in the
4
+ Software without restriction, including without
5
+ limitation the rights to use, copy, modify, merge,
6
+ publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software
8
+ is furnished to do so, subject to the following
9
+ conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ shall be included in all copies or substantial portions
13
+ of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
17
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
18
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
19
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
22
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
23
+ DEALINGS IN THE SOFTWARE.
@@ -14,15 +14,15 @@ Built-in support for SvelteKit, Astro, SolidStart, Nuxt and Analog. Any other fr
14
14
 
15
15
  - macOS 14 or later, with [Xcode](https://apps.apple.com/app/xcode/id497799835) installed
16
16
  - [XcodeGen](https://github.com/yonaskolb/XcodeGen)
17
- - [Bun](https://bun.sh), which runs the `quicklook` CLI
17
+ - [Node.js](https://nodejs.org) 22.17 or later, which runs the `quicklook` CLI
18
18
 
19
19
  ## Installation
20
20
 
21
21
  ```sh
22
- bun add --dev quicklook-kit
22
+ npm install --save-dev quicklook-kit
23
23
  ```
24
24
 
25
- To start from scratch, use one of the [templates](https://github.com/idleberg/quicklook-kit/tree/main/templates) instead.
25
+ To start from scratch, run `pnpm create quicklook` instead.
26
26
 
27
27
  ## Setup
28
28
 
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BQ-9qYNV.mjs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { cp, glob, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
8
+ import process from "node:process";
9
+ import { fileURLToPath } from "node:url";
10
+ //#region src/cli.ts
11
+ const config = await loadConfig();
12
+ const { root, name, bundleId, macOS, types, framework, icon } = config;
13
+ const target = name.replace(/[^A-Za-z0-9]/g, "");
14
+ const work = `${root}/.quicklook`;
15
+ const web = `${work}/web`;
16
+ const built = `${work}/build/${name}.app`;
17
+ const installed = `/Applications/${name}.app`;
18
+ const native = fileURLToPath(new URL("../native", import.meta.url));
19
+ const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
20
+ const leadingV = /^v/;
21
+ const inlineScript = /<script\b(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
22
+ /** Runs a command, exiting with its status if it fails unless `nothrow` is set. */
23
+ function exec([file = "", ...args], { quiet = false, nothrow = false, env = process.env } = {}) {
24
+ const { status, error } = spawnSync(file, args, {
25
+ cwd: root,
26
+ env,
27
+ stdio: quiet ? "ignore" : "inherit"
28
+ });
29
+ if (error) throw error;
30
+ if (status !== 0 && !nothrow) process.exit(status ?? 1);
31
+ }
32
+ /** `node_modules/.bin` of the project and every folder above it, as workspaces hoist binaries. */
33
+ function binPath() {
34
+ const dirs = [];
35
+ for (let dir = root;; dir = dirname(dir)) {
36
+ dirs.push(join(dir, "node_modules/.bin"));
37
+ if (dir === dirname(dir)) return [...dirs, process.env.PATH].join(delimiter);
38
+ }
39
+ }
40
+ /** Runs a framework command with the project's own binaries. */
41
+ function run(command) {
42
+ exec(command, { env: {
43
+ ...process.env,
44
+ PATH: binPath()
45
+ } });
46
+ }
47
+ /** Frameworks bootstrap with inline scripts; the CSP allows exactly those, by hash. */
48
+ async function inlineScriptHashes() {
49
+ const hashes = /* @__PURE__ */ new Set();
50
+ for await (const file of glob("**/*.html", { cwd: web })) {
51
+ const html = await readFile(`${web}/${file}`, "utf8");
52
+ for (const [, body = ""] of html.matchAll(inlineScript)) hashes.add(`'sha256-${createHash("sha256").update(body).digest("base64")}'`);
53
+ }
54
+ return [...hashes];
55
+ }
56
+ /** Builds the page with the framework and copies it to `.quicklook/web`. */
57
+ async function buildWeb() {
58
+ run(framework.build.command);
59
+ const outDir = resolve(root, framework.build.outDir);
60
+ for (const integration of config.integrations) await integration.hooks?.["build:done"]?.({
61
+ outDir,
62
+ config
63
+ });
64
+ await rm(web, {
65
+ recursive: true,
66
+ force: true
67
+ });
68
+ await cp(outDir, web, { recursive: true });
69
+ }
70
+ async function build() {
71
+ await buildWeb();
72
+ const csp = addSources(config.csp, "script-src", ...await inlineScriptHashes());
73
+ const spec = {
74
+ name: target,
75
+ options: {
76
+ deploymentTarget: { macOS },
77
+ createIntermediateGroups: true
78
+ },
79
+ settings: { base: {
80
+ SWIFT_VERSION: "6.0",
81
+ MARKETING_VERSION: process.env.VERSION?.replace(leadingV, "") ?? "1.0",
82
+ CURRENT_PROJECT_VERSION: "1",
83
+ CODE_SIGN_STYLE: "Manual",
84
+ CODE_SIGN_IDENTITY: "-"
85
+ } },
86
+ targets: {
87
+ [target]: {
88
+ type: "application",
89
+ platform: "macOS",
90
+ sources: [`${native}/App`, ...icon ? [{
91
+ path: resolve(root, icon),
92
+ buildPhase: "resources"
93
+ }] : []],
94
+ settings: {
95
+ PRODUCT_NAME: name,
96
+ PRODUCT_BUNDLE_IDENTIFIER: bundleId
97
+ },
98
+ dependencies: [{
99
+ target: `${target}Extension`,
100
+ embed: true,
101
+ codeSign: true
102
+ }],
103
+ info: {
104
+ path: `${work}/App/Info.plist`,
105
+ properties: {
106
+ CFBundleName: name,
107
+ ...icon && { CFBundleIconFile: basename(icon, ".icns") },
108
+ LSMinimumSystemVersion: macOS,
109
+ UTExportedTypeDeclarations: types.filter((type) => typeof type !== "string").map((type) => ({
110
+ UTTypeIdentifier: type.id,
111
+ UTTypeDescription: type.description,
112
+ UTTypeConformsTo: type.conformsTo,
113
+ UTTypeTagSpecification: { "public.filename-extension": type.extensions }
114
+ }))
115
+ }
116
+ }
117
+ },
118
+ [`${target}Extension`]: {
119
+ type: "app-extension",
120
+ platform: "macOS",
121
+ sources: [`${native}/Extension`, {
122
+ path: web,
123
+ type: "folder",
124
+ buildPhase: "resources"
125
+ }],
126
+ settings: { PRODUCT_BUNDLE_IDENTIFIER: `${bundleId}.Extension` },
127
+ dependencies: [{ sdk: "QuickLookUI.framework" }, { sdk: "WebKit.framework" }],
128
+ entitlements: {
129
+ path: `${work}/Extension/Extension.entitlements`,
130
+ properties: {
131
+ "com.apple.security.app-sandbox": true,
132
+ "com.apple.security.files.user-selected.read-only": true,
133
+ "com.apple.security.network.client": true
134
+ }
135
+ },
136
+ info: {
137
+ path: `${work}/Extension/Info.plist`,
138
+ properties: {
139
+ CFBundleDisplayName: name,
140
+ QLWebContentSecurityPolicy: serializeCsp(csp),
141
+ QLMaxFileSize: config.maxFileSize,
142
+ NSExtension: {
143
+ NSExtensionPointIdentifier: "com.apple.quicklook.preview",
144
+ NSExtensionPrincipalClass: "PreviewViewController",
145
+ NSExtensionAttributes: {
146
+ QLSupportedContentTypes: types.map((type) => typeof type === "string" ? type : type.id),
147
+ QLSupportsSearchableItems: false
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ };
155
+ for (const integration of config.integrations) await integration.hooks?.["xcode:spec"]?.(spec, config);
156
+ await writeFile(`${work}/project.yml`, JSON.stringify(spec, null, 2));
157
+ exec([
158
+ "xcodegen",
159
+ "generate",
160
+ "--quiet",
161
+ "--spec",
162
+ `${work}/project.yml`,
163
+ "--project",
164
+ work
165
+ ]);
166
+ exec([
167
+ "xcodebuild",
168
+ "-quiet",
169
+ "-project",
170
+ `${work}/${target}.xcodeproj`,
171
+ "-scheme",
172
+ target,
173
+ "-configuration",
174
+ "Release",
175
+ "-destination",
176
+ "generic/platform=macOS",
177
+ `CONFIGURATION_BUILD_DIR=${work}/build`,
178
+ "build"
179
+ ]);
180
+ exec(["touch", built]);
181
+ console.log(`Built ${built}`);
182
+ }
183
+ switch (process.argv[2]) {
184
+ case "dev":
185
+ run(framework.build.dev);
186
+ break;
187
+ case "build:web":
188
+ await buildWeb();
189
+ break;
190
+ case "build":
191
+ await build();
192
+ break;
193
+ case "register":
194
+ if (!existsSync(built)) throw new Error(`No build at ${built}, run 'quicklook build' first.`);
195
+ await rm(installed, {
196
+ recursive: true,
197
+ force: true
198
+ });
199
+ exec([
200
+ "cp",
201
+ "-R",
202
+ built,
203
+ installed
204
+ ]);
205
+ exec([
206
+ lsregister,
207
+ "-f",
208
+ "-R",
209
+ installed
210
+ ]);
211
+ exec(["qlmanage", "-r"], {
212
+ quiet: true,
213
+ nothrow: true
214
+ });
215
+ exec([
216
+ "qlmanage",
217
+ "-r",
218
+ "cache"
219
+ ], {
220
+ quiet: true,
221
+ nothrow: true
222
+ });
223
+ exec([
224
+ "open",
225
+ "-g",
226
+ installed
227
+ ]);
228
+ console.log(`Installed ${installed}\nVerify with: pluginkit -mAv | grep ${bundleId}`);
229
+ break;
230
+ case "unregister":
231
+ exec([
232
+ lsregister,
233
+ "-u",
234
+ installed
235
+ ], {
236
+ quiet: true,
237
+ nothrow: true
238
+ });
239
+ await rm(installed, {
240
+ recursive: true,
241
+ force: true
242
+ });
243
+ exec(["qlmanage", "-r"], {
244
+ quiet: true,
245
+ nothrow: true
246
+ });
247
+ console.log(`Removed ${installed}`);
248
+ break;
249
+ default: throw new Error("Usage: quicklook dev|build:web|build|register|unregister");
250
+ }
251
+ //#endregion
252
+ export {};
@@ -0,0 +1,113 @@
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 };
@@ -0,0 +1,80 @@
1
+ import { Plugin } from "vite";
2
+ //#region src/config.d.ts
3
+ /** An existing type id such as "public.json", or a new type the app declares. */
4
+ export type FileType = string | DeclaredType;
5
+ /** A file type the app declares itself, as a UTExportedTypeDeclaration. */
6
+ export interface DeclaredType {
7
+ id: string;
8
+ description: string;
9
+ extensions: string[];
10
+ conformsTo: string[];
11
+ }
12
+ /** CSP directives, e.g. `{ 'img-src': ["'self'", 'https:'] }`. */
13
+ export type Csp = Partial<Record<string, string[]>>;
14
+ /** The xcodegen project spec, written as project.yml. */
15
+ export type XcodeSpec = Record<string, any>;
16
+ export interface Integration {
17
+ name: string;
18
+ /** Set by frameworks only: how to run and build the page, and where it lands. */
19
+ build?: {
20
+ dev: string[];
21
+ command: string[];
22
+ outDir: string;
23
+ };
24
+ hooks?: {
25
+ 'config:resolved'?: (config: ResolvedConfig) => void | Promise<void>;
26
+ /** Runs on the framework's static output, before it's bundled into the app. */
27
+ 'build:done'?: (context: {
28
+ outDir: string;
29
+ config: ResolvedConfig;
30
+ }) => void | Promise<void>;
31
+ /** Edit the generated Xcode project: Info.plist keys, entitlements, targets. */
32
+ 'xcode:spec'?: (spec: XcodeSpec, config: ResolvedConfig) => void | Promise<void>;
33
+ /** Vite plugins to add wherever the `quicklook()` Vite plugin is used. */
34
+ vite?: () => Plugin[];
35
+ };
36
+ }
37
+ /**
38
+ * A built-in framework or package name, the same with options, or an
39
+ * integration object. Names are imported from the project, so JSON and YAML
40
+ * configs can use third-party integrations too: the module's default export is
41
+ * either an integration or a function that takes the options and returns one.
42
+ */
43
+ export type IntegrationRef = Integration | string | [name: string, options?: unknown];
44
+ export interface QuickLookConfig {
45
+ /** App name, shown in Finder and System Settings. */
46
+ name: string;
47
+ bundleId: string;
48
+ /** Path to the app's .icns icon, relative to the project root. */
49
+ icon?: string;
50
+ /** Minimum macOS version. Defaults to 14.0. */
51
+ macOS?: string;
52
+ types: FileType[];
53
+ /** `sveltekit`, `astro`, `solid-start`, `nuxt`, `analog`, or any integration that sets `build`. */
54
+ framework: IntegrationRef;
55
+ integrations?: IntegrationRef[];
56
+ /** Directives given here replace the defaults of the same name. */
57
+ csp?: Csp;
58
+ /** Larger files get QuickLook's generic preview. Defaults to 10 MB. */
59
+ maxFileSize?: number;
60
+ /** Exposed to the page as `config.public` from `virtual:quicklook`. */
61
+ public?: Record<string, unknown>;
62
+ }
63
+ /** What the page sees as `config` from `virtual:quicklook`. */
64
+ export type PageConfig = Pick<ResolvedConfig, 'name' | 'bundleId' | 'types' | 'public'>;
65
+ export interface ResolvedConfig extends Required<Omit<QuickLookConfig, 'framework' | 'integrations' | 'icon'>>, Pick<QuickLookConfig, 'icon'> {
66
+ root: string;
67
+ framework: Integration & {
68
+ build: NonNullable<Integration['build']>;
69
+ };
70
+ /** The framework first, then the configured integrations, in order. */
71
+ integrations: Integration[];
72
+ }
73
+ /** The previewed file is untrusted input; nothing on the page needs the network. */
74
+ export declare const defaultCsp: Csp;
75
+ export declare const defineConfig: (config: QuickLookConfig) => QuickLookConfig;
76
+ export declare const serializeCsp: (csp: Csp) => string;
77
+ /** `csp` with `sources` appended to `directive`. */
78
+ export declare const addSources: (csp: Csp, directive: string, ...sources: string[]) => Csp;
79
+ export declare function loadConfig(root?: string): Promise<ResolvedConfig>;
80
+ //#endregion
@@ -0,0 +1 @@
1
+ export { t as addSources, n as defaultCsp, r as defineConfig, i as loadConfig, a as serializeCsp } from "./config-BQ-9qYNV.mjs";
@@ -0,0 +1,23 @@
1
+ //#region src/runtime.d.ts
2
+ /** The file QuickLook asked to preview. */
3
+ export interface QuickLookFile {
4
+ name: string;
5
+ /** Lowercase, without the dot. */
6
+ extension: string;
7
+ bytes: Uint8Array;
8
+ }
9
+ declare global {
10
+ interface Window {
11
+ /** Injected by the extension before the page loads. See native/Extension/PreviewViewController.swift. */
12
+ quicklook?: {
13
+ name: string;
14
+ extension: string;
15
+ base64: string;
16
+ };
17
+ }
18
+ }
19
+ /** The previewed file inside QuickLook; `undefined` in the browser. */
20
+ export declare function nativeFile(): QuickLookFile | undefined;
21
+ /** The same shape from a file picked or dropped in the browser, for the dev server. */
22
+ export declare function browserFile(file: File): Promise<QuickLookFile>;
23
+ //#endregion
@@ -0,0 +1,21 @@
1
+ //#region src/runtime.ts
2
+ /** The previewed file inside QuickLook; `undefined` in the browser. */
3
+ function nativeFile() {
4
+ const injected = window.quicklook;
5
+ if (!injected) return;
6
+ return {
7
+ name: injected.name,
8
+ extension: injected.extension,
9
+ bytes: Uint8Array.from(atob(injected.base64), (c) => c.charCodeAt(0))
10
+ };
11
+ }
12
+ /** The same shape from a file picked or dropped in the browser, for the dev server. */
13
+ async function browserFile(file) {
14
+ return {
15
+ name: file.name,
16
+ extension: file.name.includes(".") ? (file.name.split(".").pop() ?? "").toLowerCase() : "",
17
+ bytes: new Uint8Array(await file.arrayBuffer())
18
+ };
19
+ }
20
+ //#endregion
21
+ export { browserFile, nativeFile };
@@ -0,0 +1,8 @@
1
+ import { Plugin } from "vite";
2
+ //#region src/vite.d.ts
3
+ /**
4
+ * Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
5
+ * the dev server report what the extension's CSP would block.
6
+ */
7
+ export declare function quicklook(): Promise<Plugin[]>;
8
+ //#endregion
package/dist/vite.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { a as serializeCsp, i as loadConfig, t as addSources } from "./config-BQ-9qYNV.mjs";
2
+ //#region src/vite.ts
3
+ const virtualId = "virtual:quicklook";
4
+ /**
5
+ * Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
6
+ * the dev server report what the extension's CSP would block.
7
+ */
8
+ async function quicklook() {
9
+ const config = await loadConfig();
10
+ const { name, bundleId, types } = config;
11
+ const pageConfig = {
12
+ name,
13
+ bundleId,
14
+ types,
15
+ public: config.public
16
+ };
17
+ const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
18
+ export { browserFile, nativeFile } from 'quicklook-kit/runtime';`;
19
+ const devCsp = serializeCsp(addSources(addSources(config.csp, "script-src", "'unsafe-inline'"), "connect-src", "'self'", "ws:"));
20
+ return [{
21
+ name: "quicklook",
22
+ resolveId: (source) => source === virtualId ? `\0${virtualId}` : void 0,
23
+ load: (source) => source === `\0${virtualId}` ? virtualSource : void 0,
24
+ configureServer(server) {
25
+ server.middlewares.use((_request, response, next) => {
26
+ response.setHeader("Content-Security-Policy-Report-Only", devCsp);
27
+ next();
28
+ });
29
+ }
30
+ }, ...config.integrations.flatMap((integration) => integration.hooks?.vite?.() ?? [])];
31
+ }
32
+ //#endregion
33
+ export { quicklook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quicklook-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Build macOS QuickLook previews with any Vite-based web framework",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -18,22 +18,40 @@
18
18
  ],
19
19
  "type": "module",
20
20
  "bin": {
21
- "quicklook": "./src/cli.ts"
21
+ "quicklook": "./dist/cli.mjs"
22
22
  },
23
23
  "exports": {
24
- ".": "./src/config.ts",
25
- "./vite": "./src/vite.ts",
26
- "./runtime": "./src/runtime.ts",
24
+ ".": {
25
+ "types": "./dist/config.d.mts",
26
+ "default": "./dist/config.mjs"
27
+ },
28
+ "./vite": {
29
+ "types": "./dist/vite.d.mts",
30
+ "default": "./dist/vite.mjs"
31
+ },
32
+ "./runtime": {
33
+ "types": "./dist/runtime.d.mts",
34
+ "default": "./dist/runtime.mjs"
35
+ },
27
36
  "./virtual": {
28
37
  "types": "./src/virtual.d.ts"
29
38
  }
30
39
  },
40
+ "files": [
41
+ "dist",
42
+ "native",
43
+ "src/virtual.d.ts"
44
+ ],
45
+ "engines": {
46
+ "node": ">=22.17"
47
+ },
31
48
  "dependencies": {
32
49
  "c12": "3.3.4",
33
50
  "jiti": "2.7.0"
34
51
  },
35
52
  "devDependencies": {
36
- "@types/bun": "1.3.9",
53
+ "@types/node": "24.19.2",
54
+ "tsdown": "0.23.0",
37
55
  "typescript": "6.0.3",
38
56
  "vite": "8.3.0"
39
57
  },
@@ -41,6 +59,7 @@
41
59
  "vite": ">=7"
42
60
  },
43
61
  "scripts": {
62
+ "build": "tsdown",
44
63
  "check": "tsc --noEmit"
45
64
  }
46
65
  }
package/src/virtual.d.ts CHANGED
@@ -2,6 +2,6 @@
2
2
  // with `/// <reference types="quicklook-kit/virtual" />`.
3
3
  declare module 'virtual:quicklook' {
4
4
  /** Build-time values from quicklook.config.*. */
5
- export const config: import('./config.ts').PageConfig;
5
+ export const config: import('quicklook-kit').PageConfig;
6
6
  export { browserFile, nativeFile, type QuickLookFile } from 'quicklook-kit/runtime';
7
7
  }
package/src/cli.ts DELETED
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env bun
2
- // Builds the page with the configured framework, wraps it in a QuickLook
3
- // extension, and installs it. Usage: quicklook dev|build:web|build|register|unregister
4
- import { createHash } from 'node:crypto';
5
- import { existsSync } from 'node:fs';
6
- import { cp, rm } from 'node:fs/promises';
7
- import { basename, resolve } from 'node:path';
8
- import process from 'node:process';
9
- import { fileURLToPath } from 'node:url';
10
- import { $, Glob } from 'bun';
11
- import {
12
- addSources,
13
- type DeclaredType,
14
- loadConfig,
15
- serializeCsp,
16
- type XcodeSpec,
17
- } from './config.ts';
18
-
19
- const config = await loadConfig();
20
- const { root, name, bundleId, macOS, types, framework, icon } = config;
21
- const target = name.replace(/[^A-Za-z0-9]/g, '');
22
- // Everything generated lives here, away from the framework's own output.
23
- const work = `${root}/.quicklook`;
24
- const web = `${work}/web`;
25
- const built = `${work}/build/${name}.app`;
26
- const installed = `/Applications/${name}.app`;
27
- const native = fileURLToPath(new URL('../native', import.meta.url));
28
- const lsregister =
29
- '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister';
30
-
31
- const leadingV = /^v/;
32
- const inlineScript = /<script\b(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
33
-
34
- /** Runs a framework command with the project's own binaries. */
35
- async function run(command: string[]) {
36
- const child = Bun.spawn(['bun', 'run', ...command], {
37
- cwd: root,
38
- stdio: ['inherit', 'inherit', 'inherit'],
39
- });
40
- const code = await child.exited;
41
- if (code !== 0) {
42
- process.exit(code);
43
- }
44
- }
45
-
46
- /** Frameworks bootstrap with inline scripts; the CSP allows exactly those, by hash. */
47
- async function inlineScriptHashes() {
48
- const hashes = new Set<string>();
49
- for await (const file of new Glob('**/*.html').scan(web)) {
50
- const html = await Bun.file(`${web}/${file}`).text();
51
- for (const [, body = ''] of html.matchAll(inlineScript)) {
52
- hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
53
- }
54
- }
55
- return [...hashes];
56
- }
57
-
58
- /** Builds the page with the framework and copies it to `.quicklook/web`. */
59
- async function buildWeb() {
60
- await run(framework.build.command);
61
- const outDir = resolve(root, framework.build.outDir);
62
- for (const integration of config.integrations) {
63
- await integration.hooks?.['build:done']?.({ outDir, config });
64
- }
65
- // Copied so the bundle's folder is always named `web`, whatever the framework calls it.
66
- await rm(web, { recursive: true, force: true });
67
- await cp(outDir, web, { recursive: true });
68
- }
69
-
70
- async function build() {
71
- await buildWeb();
72
-
73
- const csp = addSources(config.csp, 'script-src', ...(await inlineScriptHashes()));
74
- const spec: XcodeSpec = {
75
- name: target,
76
- options: { deploymentTarget: { macOS }, createIntermediateGroups: true },
77
- settings: {
78
- base: {
79
- SWIFT_VERSION: '6.0',
80
- MARKETING_VERSION: process.env.VERSION?.replace(leadingV, '') ?? '1.0',
81
- CURRENT_PROJECT_VERSION: '1',
82
- // Ad-hoc signing: enough for QuickLook to load it, no Apple Developer account needed.
83
- CODE_SIGN_STYLE: 'Manual',
84
- CODE_SIGN_IDENTITY: '-',
85
- },
86
- },
87
- targets: {
88
- [target]: {
89
- type: 'application',
90
- platform: 'macOS',
91
- sources: [
92
- `${native}/App`,
93
- ...(icon ? [{ path: resolve(root, icon), buildPhase: 'resources' }] : []),
94
- ],
95
- settings: { PRODUCT_NAME: name, PRODUCT_BUNDLE_IDENTIFIER: bundleId },
96
- dependencies: [{ target: `${target}Extension`, embed: true, codeSign: true }],
97
- info: {
98
- path: `${work}/App/Info.plist`,
99
- properties: {
100
- CFBundleName: name,
101
- ...(icon && { CFBundleIconFile: basename(icon, '.icns') }),
102
- LSMinimumSystemVersion: macOS,
103
- UTExportedTypeDeclarations: types
104
- .filter((type): type is DeclaredType => typeof type !== 'string')
105
- .map((type) => ({
106
- UTTypeIdentifier: type.id,
107
- UTTypeDescription: type.description,
108
- UTTypeConformsTo: type.conformsTo,
109
- UTTypeTagSpecification: { 'public.filename-extension': type.extensions },
110
- })),
111
- },
112
- },
113
- },
114
- [`${target}Extension`]: {
115
- type: 'app-extension',
116
- platform: 'macOS',
117
- sources: [`${native}/Extension`, { path: web, type: 'folder', buildPhase: 'resources' }],
118
- // macOS silently ignores an extension whose bundle id isn't prefixed by its app's.
119
- settings: { PRODUCT_BUNDLE_IDENTIFIER: `${bundleId}.Extension` },
120
- dependencies: [{ sdk: 'QuickLookUI.framework' }, { sdk: 'WebKit.framework' }],
121
- entitlements: {
122
- path: `${work}/Extension/Extension.entitlements`,
123
- properties: {
124
- 'com.apple.security.app-sandbox': true,
125
- 'com.apple.security.files.user-selected.read-only': true,
126
- // WKWebView's content process won't launch in a sandbox without this,
127
- // even for a page that loads nothing. The page's CSP blocks all requests.
128
- 'com.apple.security.network.client': true,
129
- },
130
- },
131
- info: {
132
- path: `${work}/Extension/Info.plist`,
133
- properties: {
134
- CFBundleDisplayName: name,
135
- // Read by PreviewViewController.swift.
136
- QLWebContentSecurityPolicy: serializeCsp(csp),
137
- QLMaxFileSize: config.maxFileSize,
138
- NSExtension: {
139
- NSExtensionPointIdentifier: 'com.apple.quicklook.preview',
140
- NSExtensionPrincipalClass: 'PreviewViewController',
141
- NSExtensionAttributes: {
142
- QLSupportedContentTypes: types.map((type) =>
143
- typeof type === 'string' ? type : type.id,
144
- ),
145
- QLSupportsSearchableItems: false,
146
- },
147
- },
148
- },
149
- },
150
- },
151
- },
152
- };
153
- for (const integration of config.integrations) {
154
- await integration.hooks?.['xcode:spec']?.(spec, config);
155
- }
156
-
157
- // JSON is valid YAML.
158
- await Bun.write(`${work}/project.yml`, JSON.stringify(spec, null, 2));
159
- await $`xcodegen generate --quiet --spec ${work}/project.yml --project ${work}`;
160
- await $`xcodebuild -quiet -project ${work}/${target}.xcodeproj -scheme ${target} -configuration Release -destination generic/platform=macOS CONFIGURATION_BUILD_DIR=${work}/build build`;
161
- // Finder keys its icon cache on the bundle's mtime, which xcodebuild leaves alone.
162
- await $`touch ${built}`;
163
- console.log(`Built ${built}`);
164
- }
165
-
166
- switch (process.argv[2]) {
167
- case 'dev':
168
- await run(framework.build.dev);
169
- break;
170
-
171
- case 'build:web':
172
- await buildWeb();
173
- break;
174
-
175
- case 'build':
176
- await build();
177
- break;
178
-
179
- // macOS only loads extensions from apps in a standard location, and caches
180
- // registrations aggressively, including for apps since renamed or deleted.
181
- case 'register':
182
- if (!existsSync(built)) {
183
- throw new Error(`No build at ${built}, run 'quicklook build' first.`);
184
- }
185
- await $`rm -rf ${installed}`;
186
- await $`cp -R ${built} ${installed}`;
187
- await $`${lsregister} -f -R ${installed}`;
188
- await $`qlmanage -r`.quiet().nothrow();
189
- await $`qlmanage -r cache`.quiet().nothrow();
190
- // Launching the app is what registers its embedded extension.
191
- await $`open -g ${installed}`;
192
- console.log(`Installed ${installed}\nVerify with: pluginkit -mAv | grep ${bundleId}`);
193
- break;
194
-
195
- case 'unregister':
196
- await $`${lsregister} -u ${installed}`.quiet().nothrow();
197
- await $`rm -rf ${installed}`;
198
- await $`qlmanage -r`.quiet().nothrow();
199
- console.log(`Removed ${installed}`);
200
- break;
201
-
202
- default:
203
- throw new Error('Usage: quicklook dev|build:web|build|register|unregister');
204
- }
package/src/config.ts DELETED
@@ -1,179 +0,0 @@
1
- // Loads quicklook.config.* (any format c12 reads: ts, js, json, yaml, toml…)
2
- // and resolves its framework and integrations.
3
-
4
- import { join } from 'node:path';
5
- import process from 'node:process';
6
- import { loadConfig as loadC12 } from 'c12';
7
- import { createJiti } from 'jiti';
8
- import type { Plugin } from 'vite';
9
-
10
- /** An existing type id such as "public.json", or a new type the app declares. */
11
- export type FileType = string | DeclaredType;
12
-
13
- /** A file type the app declares itself, as a UTExportedTypeDeclaration. */
14
- export interface DeclaredType {
15
- id: string;
16
- description: string;
17
- extensions: string[];
18
- conformsTo: string[];
19
- }
20
-
21
- /** CSP directives, e.g. `{ 'img-src': ["'self'", 'https:'] }`. */
22
- export type Csp = Partial<Record<string, string[]>>;
23
-
24
- /** The xcodegen project spec, written as project.yml. */
25
- // biome-ignore lint/suspicious/noExplicitAny: hooks edit arbitrary xcodegen keys.
26
- export type XcodeSpec = Record<string, any>;
27
-
28
- export interface Integration {
29
- name: string;
30
- /** Set by frameworks only: how to run and build the page, and where it lands. */
31
- build?: { dev: string[]; command: string[]; outDir: string };
32
- hooks?: {
33
- 'config:resolved'?: (config: ResolvedConfig) => void | Promise<void>;
34
- /** Runs on the framework's static output, before it's bundled into the app. */
35
- 'build:done'?: (context: { outDir: string; config: ResolvedConfig }) => void | Promise<void>;
36
- /** Edit the generated Xcode project: Info.plist keys, entitlements, targets. */
37
- 'xcode:spec'?: (spec: XcodeSpec, config: ResolvedConfig) => void | Promise<void>;
38
- /** Vite plugins to add wherever the `quicklook()` Vite plugin is used. */
39
- vite?: () => Plugin[];
40
- };
41
- }
42
-
43
- /**
44
- * A built-in framework or package name, the same with options, or an
45
- * integration object. Names are imported from the project, so JSON and YAML
46
- * configs can use third-party integrations too: the module's default export is
47
- * either an integration or a function that takes the options and returns one.
48
- */
49
- export type IntegrationRef = Integration | string | [name: string, options?: unknown];
50
-
51
- export interface QuickLookConfig {
52
- /** App name, shown in Finder and System Settings. */
53
- name: string;
54
- bundleId: string;
55
- /** Path to the app's .icns icon, relative to the project root. */
56
- icon?: string;
57
- /** Minimum macOS version. Defaults to 14.0. */
58
- macOS?: string;
59
- types: FileType[];
60
- /** `sveltekit`, `astro`, `solid-start`, `nuxt`, `analog`, or any integration that sets `build`. */
61
- framework: IntegrationRef;
62
- integrations?: IntegrationRef[];
63
- /** Directives given here replace the defaults of the same name. */
64
- csp?: Csp;
65
- /** Larger files get QuickLook's generic preview. Defaults to 10 MB. */
66
- maxFileSize?: number;
67
- /** Exposed to the page as `config.public` from `virtual:quicklook`. */
68
- public?: Record<string, unknown>;
69
- }
70
-
71
- /** What the page sees as `config` from `virtual:quicklook`. */
72
- export type PageConfig = Pick<ResolvedConfig, 'name' | 'bundleId' | 'types' | 'public'>;
73
-
74
- export interface ResolvedConfig
75
- extends Required<Omit<QuickLookConfig, 'framework' | 'integrations' | 'icon'>>,
76
- Pick<QuickLookConfig, 'icon'> {
77
- root: string;
78
- framework: Integration & { build: NonNullable<Integration['build']> };
79
- /** The framework first, then the configured integrations, in order. */
80
- integrations: Integration[];
81
- }
82
-
83
- /** The previewed file is untrusted input; nothing on the page needs the network. */
84
- export const defaultCsp: Csp = {
85
- 'default-src': ["'none'"],
86
- 'script-src': ["'self'"],
87
- 'style-src': ["'self'", "'unsafe-inline'"],
88
- 'img-src': ["'self'", 'data:', 'blob:'],
89
- 'font-src': ["'self'", 'data:'],
90
- 'media-src': ["'self'", 'data:', 'blob:'],
91
- };
92
-
93
- export const defineConfig = (config: QuickLookConfig) => config;
94
-
95
- export const serializeCsp = (csp: Csp) =>
96
- Object.entries(csp)
97
- .map(([directive, sources = []]) => [directive, ...sources].join(' '))
98
- .join('; ');
99
-
100
- /** `csp` with `sources` appended to `directive`. */
101
- export const addSources = (csp: Csp, directive: string, ...sources: string[]): Csp => ({
102
- ...csp,
103
- [directive]: [...(csp[directive] ?? []), ...sources],
104
- });
105
-
106
- /**
107
- * Built-in frameworks: their dev and build commands, and the default `outDir`
108
- * of a static, client-side build. Each takes `{ outDir }` to override it.
109
- */
110
- const frameworkBuilds: Record<string, [dev: string[], command: string[], outDir: string]> = {
111
- // @analogjs/vite-plugin-angular, writing Vite's default `dist`.
112
- analog: [['vite', 'dev'], ['vite', 'build'], 'dist'],
113
- // Astro's default `output: 'static'`.
114
- astro: [['astro', 'dev'], ['astro', 'build'], 'dist'],
115
- // `ssr: false`; `nuxt generate` writes `.output/public`.
116
- nuxt: [['nuxt', 'dev'], ['nuxt', 'generate'], '.output/public'],
117
- // `solidStart({ ssr: false })` and Nitro's static preset prerendering `/`.
118
- 'solid-start': [['vite', 'dev'], ['vite', 'build'], '.output/public'],
119
- // @sveltejs/adapter-static, writing `build`.
120
- sveltekit: [['vite', 'dev'], ['vite', 'build'], 'build'],
121
- };
122
-
123
- function builtinFramework(name: string, options?: unknown): Integration | undefined {
124
- const builtin = frameworkBuilds[name];
125
- if (!builtin) {
126
- return;
127
- }
128
- const [dev, command, outDir] = builtin;
129
- return {
130
- name,
131
- build: { dev, command, outDir: (options as { outDir?: string } | undefined)?.outDir ?? outDir },
132
- };
133
- }
134
-
135
- async function resolveIntegration(ref: IntegrationRef, root: string): Promise<Integration> {
136
- if (typeof ref === 'object' && !Array.isArray(ref)) {
137
- return ref;
138
- }
139
- const [name, options] = typeof ref === 'string' ? [ref] : ref;
140
- const builtin = builtinFramework(name, options);
141
- if (builtin) {
142
- return builtin;
143
- }
144
- const exported = await createJiti(join(root, 'quicklook.config')).import<
145
- Integration | ((options?: unknown) => Integration)
146
- >(name, { default: true });
147
- return typeof exported === 'function' ? exported(options) : exported;
148
- }
149
-
150
- export async function loadConfig(root = process.cwd()): Promise<ResolvedConfig> {
151
- const { config } = await loadC12<QuickLookConfig>({ name: 'quicklook', cwd: root });
152
- if (!config?.name) {
153
- throw new Error(`No quicklook.config.* with a name found in ${root}`);
154
- }
155
- const { framework: frameworkRef, integrations: refs = [], ...rest } = config;
156
- const framework = await resolveIntegration(frameworkRef, root);
157
- if (!framework.build) {
158
- throw new Error(`Framework "${framework.name}" doesn't say how to build the page`);
159
- }
160
- const integrations = [framework];
161
- for (const ref of refs) {
162
- integrations.push(await resolveIntegration(ref, root));
163
- }
164
-
165
- const resolved: ResolvedConfig = {
166
- macOS: '14.0',
167
- maxFileSize: 10_000_000,
168
- public: {},
169
- ...rest,
170
- csp: { ...defaultCsp, ...config.csp },
171
- root,
172
- framework: { ...framework, build: framework.build },
173
- integrations,
174
- };
175
- for (const integration of integrations) {
176
- await integration.hooks?.['config:resolved']?.(resolved);
177
- }
178
- return resolved;
179
- }
package/src/runtime.ts DELETED
@@ -1,36 +0,0 @@
1
- /** The file QuickLook asked to preview. */
2
- export interface QuickLookFile {
3
- name: string;
4
- /** Lowercase, without the dot. */
5
- extension: string;
6
- bytes: Uint8Array;
7
- }
8
-
9
- declare global {
10
- interface Window {
11
- /** Injected by the extension before the page loads. See native/Extension/PreviewViewController.swift. */
12
- quicklook?: { name: string; extension: string; base64: string };
13
- }
14
- }
15
-
16
- /** The previewed file inside QuickLook; `undefined` in the browser. */
17
- export function nativeFile(): QuickLookFile | undefined {
18
- const injected = window.quicklook;
19
- if (!injected) {
20
- return;
21
- }
22
- return {
23
- name: injected.name,
24
- extension: injected.extension,
25
- bytes: Uint8Array.from(atob(injected.base64), (c) => c.charCodeAt(0)),
26
- };
27
- }
28
-
29
- /** The same shape from a file picked or dropped in the browser, for the dev server. */
30
- export async function browserFile(file: File): Promise<QuickLookFile> {
31
- return {
32
- name: file.name,
33
- extension: file.name.includes('.') ? (file.name.split('.').pop() ?? '').toLowerCase() : '',
34
- bytes: new Uint8Array(await file.arrayBuffer()),
35
- };
36
- }
package/src/vite.ts DELETED
@@ -1,41 +0,0 @@
1
- import type { Plugin } from 'vite';
2
- import { addSources, loadConfig, type PageConfig, serializeCsp } from './config.ts';
3
-
4
- const virtualId = 'virtual:quicklook';
5
-
6
- /**
7
- * Provides `virtual:quicklook`, adds the integrations' Vite plugins, and makes
8
- * the dev server report what the extension's CSP would block.
9
- */
10
- export async function quicklook(): Promise<Plugin[]> {
11
- const config = await loadConfig();
12
- const { name, bundleId, types } = config;
13
- const pageConfig: PageConfig = { name, bundleId, types, public: config.public };
14
- const virtualSource = `export const config = ${JSON.stringify(pageConfig)};
15
- export { browserFile, nativeFile } from 'quicklook-kit/runtime';`;
16
- // Report-only, so violations show in the console without breaking HMR. Inline
17
- // scripts are only hashed at build time, and the dev server needs its socket.
18
- const devCsp = serializeCsp(
19
- addSources(
20
- addSources(config.csp, 'script-src', "'unsafe-inline'"),
21
- 'connect-src',
22
- "'self'",
23
- 'ws:',
24
- ),
25
- );
26
-
27
- return [
28
- {
29
- name: 'quicklook',
30
- resolveId: (source) => (source === virtualId ? `\0${virtualId}` : undefined),
31
- load: (source) => (source === `\0${virtualId}` ? virtualSource : undefined),
32
- configureServer(server) {
33
- server.middlewares.use((_request, response, next) => {
34
- response.setHeader('Content-Security-Policy-Report-Only', devCsp);
35
- next();
36
- });
37
- },
38
- },
39
- ...config.integrations.flatMap((integration) => integration.hooks?.vite?.() ?? []),
40
- ];
41
- }
package/tsconfig.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "es2023",
4
- "module": "preserve",
5
- "moduleResolution": "bundler",
6
- "lib": ["es2023", "dom"],
7
- "types": ["bun"],
8
- "strict": true,
9
- "noUncheckedIndexedAccess": true,
10
- "noEmit": true,
11
- "allowImportingTsExtensions": true,
12
- "verbatimModuleSyntax": true,
13
- "skipLibCheck": true
14
- },
15
- "include": ["src"]
16
- }