Skip to content

Commit acd3aee

Browse files
authored
Merge pull request #10218 from quarto-dev/feature/brand-yaml
(`brand.yml`) sass - create explicit sass cache object, use deno.kv
2 parents 529ca3d + 6b87edc commit acd3aee

File tree

12 files changed

+224
-58
lines changed

12 files changed

+224
-58
lines changed

.vscode/launch.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"runtimeExecutable": "${workspaceFolder}/package/dist/bin/tools/deno",
1515
"runtimeArgs": [
1616
"run",
17+
"--unstable-kv",
1718
"--unstable-ffi",
1819
"--importmap=${workspaceFolder}/src/import_map.json",
1920
"--inspect-brk",
@@ -42,6 +43,7 @@
4243
"runtimeArgs": [
4344
"test",
4445
"--config=test-conf.json",
46+
"--unstable-kv",
4547
"--unstable-ffi",
4648
"--allow-all",
4749
"--check",

package/launcher/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ fn main() {
8282

8383
// Define the base deno options
8484
let mut deno_options: Vec<String> = vec![
85+
String::from("--unstable-kv"),
8586
String::from("--unstable-ffi"),
8687
String::from("--no-config"),
8788
String::from("--cached-only"),

package/scripts/common/quarto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ fi
169169
export DENO_TLS_CA_STORE=system,mozilla
170170
export DENO_NO_UPDATE_CHECK=1
171171
# Be sure to include any already defined QUARTO_DENO_OPTIONS
172-
QUARTO_DENO_OPTIONS="--unstable-ffi --no-config ${QUARTO_CACHE_OPTIONS} --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi ${QUARTO_DENO_OPTIONS}"
172+
QUARTO_DENO_OPTIONS="--unstable-ffi --unstable-kv --no-config ${QUARTO_CACHE_OPTIONS} --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi ${QUARTO_DENO_OPTIONS}"
173173

174174
# --enable-experimental-regexp-engine is required for /regex/l, https://github.com/quarto-dev/quarto-cli/issues/9737
175175
if [ "$QUARTO_DENO_V8_OPTIONS" != "" ]; then

package/scripts/windows/quarto.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ IF NOT DEFINED QUARTO_DENO (
9999

100100
SET "DENO_TLS_CA_STORE=system,mozilla"
101101
SET "DENO_NO_UPDATE_CHECK=1"
102-
SET "QUARTO_DENO_OPTIONS=--unstable-ffi --no-config --cached-only --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi"
102+
SET "QUARTO_DENO_OPTIONS=--unstable-kv --unstable-ffi --no-config --cached-only --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi"
103103

104104
REM Add expected V8 options to QUARTO_DENO_V8_OPTIONS
105105
IF DEFINED QUARTO_DENO_V8_OPTIONS (

package/src/quarto-bld

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ export DENO_NO_UPDATE_CHECK=1
1717

1818
# TODO: Consider generating a source map or something to get a good stack
1919
# Create the Deno bundle
20-
"$QUARTO_DENO" run --unstable-ffi --allow-env --allow-read --allow-write --allow-run --allow-net --allow-ffi --v8-flags=--stack-trace-limit=100 --importmap="${SCRIPT_PATH}/../../src/dev_import_map.json" "$SCRIPT_PATH/bld.ts" $@
20+
"$QUARTO_DENO" run --unstable-kv --unstable-ffi --allow-env --allow-read --allow-write --allow-run --allow-net --allow-ffi --v8-flags=--stack-trace-limit=100 --importmap="${SCRIPT_PATH}/../../src/dev_import_map.json" "$SCRIPT_PATH/bld.ts" $@

package/src/quarto-bld.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ if NOT DEFINED QUARTO_DENO (
99
SET "RUST_BACKTRACE=full"
1010
SET "DENO_NO_UPDATE_CHECK=1"
1111

12-
"%QUARTO_DENO%" run --unstable-ffi --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --importmap=%~dp0\..\..\src\dev_import_map.json %~dp0\bld.ts %*
12+
"%QUARTO_DENO%" run --unstable-kv --unstable-ffi --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --importmap=%~dp0\..\..\src\dev_import_map.json %~dp0\bld.ts %*

package/src/util/deno.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export async function bundle(
2020
}
2121
denoBundleCmd.push(denoExecPath);
2222
denoBundleCmd.push("bundle");
23+
denoBundleCmd.push("--unstable-kv");
2324
denoBundleCmd.push("--unstable-ffi");
2425
denoBundleCmd.push(
2526
"--importmap=" + configuration.importmap,
@@ -54,6 +55,7 @@ export async function compile(
5455
}
5556
denoBundleCmd.push(denoExecPath);
5657
denoBundleCmd.push("compile");
58+
denoBundleCmd.push("--unstable-kv");
5759
denoBundleCmd.push("--unstable-ffi");
5860
denoBundleCmd.push(
5961
"--importmap=" + configuration.importmap,
@@ -85,6 +87,7 @@ export async function install(
8587
}
8688
denoBundleCmd.push(denoExecPath);
8789
denoBundleCmd.push("install");
90+
denoBundleCmd.push("--unstable-kv");
8891
denoBundleCmd.push("--unstable-ffi");
8992
denoBundleCmd.push(
9093
"--importmap=" + configuration.importmap,

src/core/run/deno.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const denoRunHandler: RunHandler = {
4444
importMap,
4545
"--cached-only",
4646
"--allow-all",
47+
"--unstable-kv",
4748
"--unstable-ffi",
4849
script,
4950
...args,

src/core/sass.ts

Lines changed: 3 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ import { dartCompile } from "./dart-sass.ts";
1515

1616
import * as ld from "./lodash.ts";
1717
import { lines } from "./text.ts";
18-
import { md5Hash } from "./hash.ts";
19-
import { debug } from "../deno_ral/log.ts";
20-
import { safeExistsSync } from "./path.ts";
18+
import { sassCache } from "./sass/cache.ts";
2119

2220
export interface SassVariable {
2321
name: string;
@@ -300,10 +298,6 @@ export async function compileWithCache(
300298
cacheIdentifier?: string,
301299
) {
302300
if (cacheIdentifier) {
303-
// Calculate a hash for the input and identifier
304-
const identifierHash = md5Hash(cacheIdentifier);
305-
const inputHash = md5Hash(input);
306-
307301
// If there are imports, the computed input Hash is incorrect
308302
// so we should be using a session cache which will cache
309303
// across renders, but not persistently
@@ -313,50 +307,8 @@ export async function compileWithCache(
313307
const cacheDir = useSessionCache
314308
? join(temp.baseDir, "sass")
315309
: quartoCacheDir("sass");
316-
const cacheIdxPath = join(cacheDir, "index.json");
317-
318-
const outputFile = `${identifierHash}.css`;
319-
const outputFilePath = join(cacheDir, outputFile);
320-
321-
// Check whether we can use a cached file
322-
let cacheIndex: { [key: string]: { key: string; hash: string } } = {};
323-
let writeCache = true;
324-
if (existsSync(outputFilePath)) {
325-
try {
326-
cacheIndex = JSON.parse(Deno.readTextFileSync(cacheIdxPath));
327-
const existingEntry = cacheIndex[identifierHash];
328-
writeCache = !existingEntry || (existingEntry.hash !== inputHash);
329-
} catch {
330-
debug(`The scss cache index file ${cacheIdxPath} can't be read.`);
331-
}
332-
}
333-
334-
// We need to refresh the cache
335-
if (writeCache) {
336-
try {
337-
await dartCompile(
338-
input,
339-
outputFilePath,
340-
temp,
341-
loadPaths,
342-
compressed,
343-
);
344-
} catch (error) {
345-
// Compilation failed, so clear out the output file (if exists)
346-
// which will be invalid CSS
347-
try {
348-
if (safeExistsSync(outputFilePath)) {
349-
Deno.removeSync(outputFilePath);
350-
}
351-
} finally {
352-
//doesn't matter
353-
}
354-
throw error;
355-
}
356-
cacheIndex[identifierHash] = { key: cacheIdentifier, hash: inputHash };
357-
Deno.writeTextFileSync(cacheIdxPath, JSON.stringify(cacheIndex));
358-
}
359-
return outputFilePath;
310+
const cache = await sassCache(cacheDir);
311+
return cache.getOrSet(input, loadPaths, temp, cacheIdentifier, compressed);
360312
} else {
361313
const outputFilePath = temp.createFile({ suffix: ".css" });
362314
// Skip the cache and just compile

src/core/sass/cache.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* cache.ts
3+
*
4+
* A persistent cache for sass compilation based partly on Deno.KV.
5+
*
6+
* Copyright (C) 2024 Posit Software, PBC
7+
*/
8+
9+
import { InternalError } from "../lib/error.ts";
10+
import { md5Hash } from "../hash.ts";
11+
import { join } from "../../deno_ral/path.ts";
12+
import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts";
13+
import { dartCompile } from "../dart-sass.ts";
14+
import { TempContext } from "../temp.ts";
15+
import { safeRemoveIfExists } from "../path.ts";
16+
import * as log from "../../deno_ral/log.ts";
17+
18+
class SassCache {
19+
kv: Deno.Kv;
20+
path: string;
21+
22+
constructor(kv: Deno.Kv, path: string) {
23+
this.kv = kv;
24+
this.path = path;
25+
}
26+
27+
async getFromHash(
28+
hash: string,
29+
inputHash: string,
30+
force?: boolean,
31+
): Promise<string | null> {
32+
log.debug(
33+
`SassCache.getFromHash(hash=${hash}, inputHash=${inputHash}, force=${force})`,
34+
);
35+
// verify that the hash is a valid md5 hash
36+
if (hash.length !== 32 || !/^[0-9a-f]{32}$/.test(hash)) {
37+
throw new InternalError(`Invalid hash length: ${hash.length}`);
38+
}
39+
40+
const result = await this.kv.get(["entry", hash]);
41+
if (result.value === null) {
42+
log.debug(` cache miss`);
43+
return null;
44+
}
45+
if (typeof result.value !== "object") {
46+
throw new InternalError(
47+
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof result
48+
.value}`,
49+
);
50+
}
51+
const v = result.value as Record<string, unknown>;
52+
if (typeof v.key !== "string" || typeof v.hash !== "string") {
53+
throw new InternalError(
54+
`Unsupported SassCache entry type\nExpected SassCacheEntry, got ${typeof result
55+
.value}`,
56+
);
57+
}
58+
const outputFilePath = join(this.path, `${hash}.css`);
59+
60+
// if the hash doesn't match the key, return null
61+
if ((v.hash !== inputHash && !force) || !existsSync(outputFilePath)) {
62+
if (v.hash !== inputHash) {
63+
log.debug(` hash mismatch: ${v.hash} !== ${inputHash}`);
64+
} else if (force) {
65+
log.debug(` forcing recomputation`);
66+
} else {
67+
log.debug(` output file missing: ${outputFilePath}`);
68+
}
69+
return null;
70+
}
71+
72+
log.debug(` cache hit`);
73+
return outputFilePath;
74+
}
75+
76+
async setFromHash(
77+
identifierHash: string,
78+
inputHash: string,
79+
input: string,
80+
loadPaths: string[],
81+
temp: TempContext,
82+
cacheIdentifier: string,
83+
compressed?: boolean,
84+
): Promise<string> {
85+
log.debug(`SassCache.setFromHash(${identifierHash}, ${inputHash}), ...`);
86+
const outputFilePath = join(this.path, `${identifierHash}.css`);
87+
try {
88+
await dartCompile(
89+
input,
90+
outputFilePath,
91+
temp,
92+
loadPaths,
93+
compressed,
94+
);
95+
} catch (error) {
96+
// Compilation failed, so clear out the output file (if exists)
97+
// which will be invalid CSS
98+
try {
99+
safeRemoveIfExists(outputFilePath);
100+
} finally {
101+
// doesn't matter
102+
}
103+
throw error;
104+
}
105+
await this.kv.set(["entry", identifierHash], {
106+
key: cacheIdentifier,
107+
hash: inputHash,
108+
});
109+
return outputFilePath;
110+
}
111+
112+
async set(
113+
input: string,
114+
loadPaths: string[],
115+
temp: TempContext,
116+
cacheIdentifier: string,
117+
compressed?: boolean,
118+
): Promise<string> {
119+
const identifierHash = md5Hash(cacheIdentifier);
120+
const inputHash = md5Hash(input);
121+
return this.setFromHash(
122+
identifierHash,
123+
inputHash,
124+
input,
125+
loadPaths,
126+
temp,
127+
cacheIdentifier,
128+
compressed,
129+
);
130+
}
131+
132+
async getOrSet(
133+
input: string,
134+
loadPaths: string[],
135+
temp: TempContext,
136+
cacheIdentifier: string,
137+
compressed?: boolean,
138+
): Promise<string> {
139+
log.debug(`SassCache.getOrSet(...)`);
140+
const identifierHash = md5Hash(cacheIdentifier);
141+
const inputHash = md5Hash(input);
142+
const existing = await this.getFromHash(identifierHash, inputHash);
143+
if (existing !== null) {
144+
log.debug(` cache hit`);
145+
return existing;
146+
}
147+
log.debug(` cache miss, setting`);
148+
return this.setFromHash(
149+
identifierHash,
150+
inputHash,
151+
input,
152+
loadPaths,
153+
temp,
154+
cacheIdentifier,
155+
compressed,
156+
);
157+
}
158+
}
159+
160+
const currentSassCacheVersion = 1;
161+
162+
const requiredQuartoVersions: Record<number, string> = {
163+
1: "1.6.0",
164+
};
165+
166+
async function checkVersion(kv: Deno.Kv, path: string) {
167+
const version = await kv.get(["version"]);
168+
if (version.value === null) {
169+
await kv.set(["version"], 1);
170+
} else {
171+
if (typeof version.value !== "number") {
172+
throw new Error(
173+
`Unsupported SassCache version type in ${path}\nExpected number, got ${typeof version
174+
.value}`,
175+
);
176+
}
177+
if (version.value < currentSassCacheVersion) {
178+
// in the future we should clean this automatically, but this is v1 and there should be
179+
// no old data anywhere.
180+
throw new Error(
181+
`Found outdated SassCache version. Please clear ${path}.`,
182+
);
183+
}
184+
if (version.value > currentSassCacheVersion) {
185+
throw new Error(
186+
`Found a SassCache version that's newer than supported. Please clear ${path} or upgrade Quarto to ${
187+
requiredQuartoVersions[currentSassCacheVersion]
188+
} or later.`,
189+
);
190+
}
191+
}
192+
}
193+
194+
const _sassCache: Record<string, SassCache> = {};
195+
export async function sassCache(path: string): Promise<SassCache> {
196+
if (!_sassCache[path]) {
197+
log.debug(`Creating SassCache at ${path}`);
198+
ensureDirSync(path);
199+
const kvFile = join(path, "sass.kv");
200+
const kv = await Deno.openKv(kvFile);
201+
await checkVersion(kv, kvFile);
202+
_sassCache[path] = new SassCache(kv, path);
203+
}
204+
log.debug(`Returning SassCache at ${path}`);
205+
const result = _sassCache[path];
206+
return result;
207+
}

0 commit comments

Comments
 (0)