add localisation and theming for playground

This commit is contained in:
Moritz Hölting 2024-06-22 14:36:30 +02:00
parent 1a5dcd24bc
commit a02af4f90c
15 changed files with 403 additions and 136 deletions

View File

@ -1,78 +1,114 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import react from "@astrojs/react";
import starlightLinksValidator from "starlight-links-validator";
import shikiConfig from './src/utils/shiki';
import starlightUtils from "@lorenzo_lewis/starlight-utils";
import shikiConfig from "./src/utils/shiki";
const playgroundSidebarEntry = {
label: "Playground",
link: "/playground",
translations: { de: "Spielplatz" },
};
const navLinks = [playgroundSidebarEntry];
// https://astro.build/config
export default defineConfig({
integrations: [react(), starlight({
title: 'ShulkerScript',
logo: {
src: './src/assets/logo.webp',
alt: 'ShulkerScript Logo'
},
favicon: '/favicon.ico',
description: 'A simple and powerful scripting language for Minecraft datapacks.',
social: {
github: 'https://github.com/moritz-hoelting/shulkerscript-cli'
},
tableOfContents: {
minHeadingLevel: 1,
maxHeadingLevel: 3
},
defaultLocale: 'root',
locales: {
root: {
label: 'English',
lang: 'en'
},
de: {
label: 'Deutsch',
lang: 'de'
}
},
editLink: {
baseUrl: 'https://github.com/moritz-hoelting/shulkerscript-webpage/edit/main'
},
customCss: ['./src/styles/style.css'],
plugins: [starlightLinksValidator({
errorOnFallbackPages: false
})],
expressiveCode: {
shiki: shikiConfig
},
components: {
PageTitle: './src/components/override/PageTitle.astro',
ContentPanel: './src/components/override/ContentPanel.astro',
},
sidebar: [{
label: 'Guides',
autogenerate: {
directory: 'guides'
},
translations: {
de: 'Anleitungen'
}
}, {
label: 'Roadmap',
link: '/roadmap',
translations: {
de: 'Zukunftspläne'
}
}, {
label: 'Reference',
autogenerate: {
directory: 'reference'
},
collapsed: true,
translations: {
de: 'Referenz'
},
badge: {
text: 'WIP',
variant: 'caution'
}
}]
})]
});
integrations: [
react(),
starlight({
title: "ShulkerScript",
logo: {
src: "./src/assets/logo.webp",
alt: "ShulkerScript Logo",
},
favicon: "/favicon.ico",
description:
"A simple and powerful scripting language for Minecraft datapacks.",
social: {
github: "https://github.com/moritz-hoelting/shulkerscript-cli",
},
tableOfContents: {
minHeadingLevel: 1,
maxHeadingLevel: 3,
},
defaultLocale: "root",
locales: {
root: {
label: "English",
lang: "en",
},
de: {
label: "Deutsch",
lang: "de",
},
},
editLink: {
baseUrl:
"https://github.com/moritz-hoelting/shulkerscript-webpage/edit/main",
},
customCss: ["./src/styles/style.css"],
plugins: [
starlightUtils({
navLinks: {
leading: {
useSidebarLabelled: "leadingNavLinks",
},
},
}),
starlightLinksValidator({
errorOnFallbackPages: false,
}),
],
expressiveCode: {
shiki: shikiConfig,
},
components: {
PageTitle: "./src/components/override/PageTitle.astro",
ContentPanel: "./src/components/override/ContentPanel.astro",
},
sidebar: [
{
label: "leadingNavLinks",
items: navLinks,
},
{
label: "Guides",
autogenerate: {
directory: "guides",
},
translations: {
de: "Anleitungen",
},
},
{
label: "More",
translations: {
de: "Mehr",
},
items: [
{
label: "Roadmap",
link: "/roadmap",
translations: {
de: "Zukunftspläne",
},
},
playgroundSidebarEntry
],
},
{
label: "Reference",
autogenerate: {
directory: "reference",
},
collapsed: true,
translations: {
de: "Referenz",
},
},
],
}),
],
});

View File

@ -15,6 +15,7 @@
"@astrojs/starlight": "^0.24.2",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@lorenzo_lewis/starlight-utils": "^0.1.1",
"@monaco-editor/react": "^4.6.0",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",

View File

@ -20,6 +20,9 @@ dependencies:
'@emotion/styled':
specifier: ^11.11.5
version: 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.3)(react@18.3.1)
'@lorenzo_lewis/starlight-utils':
specifier: ^0.1.1
version: 0.1.1(@astrojs/starlight@0.24.2)(astro@4.10.2)
'@monaco-editor/react':
specifier: ^4.6.0
version: 4.6.0(monaco-editor@0.49.0)(react-dom@18.3.1)(react@18.3.1)
@ -1410,6 +1413,19 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15
dev: false
/@lorenzo_lewis/starlight-utils@0.1.1(@astrojs/starlight@0.24.2)(astro@4.10.2):
resolution: {integrity: sha512-WBbZ9tnLxRsiiNVBzyNrANbl098/wMt7gVT09XCJtHZiiOlYQFBBYywgk/vAwzgicUe3vb27MBFc6jDOqvmu5w==}
peerDependencies:
'@astrojs/starlight': '>=0.16.0'
astro: '>=4.0.0'
dependencies:
'@astrojs/starlight': 0.24.2(astro@4.10.2)
astro: 4.10.2(sass@1.77.6)(typescript@5.4.5)
astro-integration-kit: 0.13.3(astro@4.10.2)
transitivePeerDependencies:
- '@astrojs/db'
dev: false
/@mdx-js/mdx@3.0.1:
resolution: {integrity: sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==}
dependencies:
@ -2161,6 +2177,13 @@ packages:
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
dev: false
/ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
dependencies:
tslib: 2.6.3
dev: false
/astring@1.8.6:
resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==}
hasBin: true
@ -2175,6 +2198,20 @@ packages:
rehype-expressive-code: 0.35.3
dev: false
/astro-integration-kit@0.13.3(astro@4.10.2):
resolution: {integrity: sha512-hUEQMnZ7z+7ySPCX6mXnIr0BFZU1+49eQQBg4aHjKGz1o2oZ5tvuB9Tlyj/orRH9ubd+Gkd0SSoldz0BTNe4Rg==}
peerDependencies:
'@astrojs/db': ^0.9 || ^0.10 || ^0.11
astro: ^4.4.1
peerDependenciesMeta:
'@astrojs/db':
optional: true
dependencies:
astro: 4.10.2(sass@1.77.6)(typescript@5.4.5)
pathe: 1.1.2
recast: 0.23.9
dev: false
/astro@4.10.2(sass@1.77.6)(typescript@5.4.5):
resolution: {integrity: sha512-SBdkoOanPsxKlKVU4uu/XG0G7NYAFoqmfBtq9SPMJ34B7Hr1MxVdEugERs8IwYN6UaxdDVcqA++9PvH6Onq2cg==}
engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'}
@ -4437,6 +4474,10 @@ packages:
engines: {node: '>=8'}
dev: false
/pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
dev: false
/periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
dependencies:
@ -4639,6 +4680,17 @@ packages:
dependencies:
picomatch: 2.3.1
/recast@0.23.9:
resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==}
engines: {node: '>= 4'}
dependencies:
ast-types: 0.16.1
esprima: 4.0.1
source-map: 0.6.1
tiny-invariant: 1.3.3
tslib: 2.6.3
dev: false
/regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
dev: false
@ -5079,6 +5131,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
dev: false
/source-map@0.7.4:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
@ -5279,6 +5336,10 @@ packages:
b4a: 1.6.6
dev: false
/tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
dev: false
/tm-themes@1.4.3:
resolution: {integrity: sha512-nsUGwktLaWFMyKw2e/hNyDmcTIO+ue6Q2Mvb8L7WQkKSCflOm2TjGSspcJw6q7hi4QxQQNTuGICyvQN1UqtunQ==}
dev: false
@ -5319,7 +5380,6 @@ packages:
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
requiresBuild: true
dev: false
optional: true
/tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useMonaco, type Monaco } from "@monaco-editor/react";
import { useImmer, type Updater } from "use-immer";
@ -14,16 +14,7 @@ import initWasm, {
compile,
compileZip,
} from "@wasm/webcompiler/pkg/webcompiler";
export type File = {
language?: string;
content: string;
};
export type Directory = {
dirs?: { [key: string]: Directory };
files?: { [key: string]: File };
};
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
import type { Directory, File, PlaygroundLang } from "@utils/playground";
const FILE_STORAGE_KEY = "playground-files";
const DEFAULT_FILES = {
@ -46,7 +37,7 @@ const DEFAULT_FILES = {
},
};
export default function Playground() {
export default function Playground({ lang }: { lang: PlaygroundLang }) {
initWasm().catch((err) => {
console.error(err);
});
@ -55,32 +46,40 @@ export default function Playground() {
getStorageOrDefault(FILE_STORAGE_KEY, DEFAULT_FILES) as Directory
);
const [theme, setTheme] = useState<"light" | "dark">("dark");
const [fileName, setFileName] = useState("src/main.shu");
const file = getFile(rootDir, fileName);
const onBuild = () => {
if (monaco) {
const dist = JSON.parse(
JSON.stringify(compile(getFiles(monaco)), jsonReplacer)
);
const withRoot = {
dirs: {
dist: dist,
},
} as Directory;
loadFiles(monaco, updateRootDir, withRoot);
const compiled = compile(getFiles(monaco));
if (compiled) {
const dist = JSON.parse(JSON.stringify(compiled, jsonReplacer));
const withRoot = {
dirs: {
dist: dist,
},
} as Directory;
loadFiles(monaco, updateRootDir, withRoot);
} else {
alert("Compilation failed");
}
} else {
console.error("monaco has not loaded");
}
};
const onZip = () => {
if (monaco) {
const data =
"data:application/zip;base64," + compileZip(getFiles(monaco));
const a = document.createElement("a");
a.href = data;
a.download = "shulkerscript-pack.zip";
a.click();
const zipped = compileZip(getFiles(monaco));
if (zipped) {
const data = "data:application/zip;base64," + zipped;
const a = document.createElement("a");
a.href = data;
a.download = "shulkerscript-pack.zip";
a.click();
} else {
alert("Compilation failed");
}
} else {
console.error("monaco has not loaded");
}
@ -125,6 +124,37 @@ export default function Playground() {
}
}, [monaco]);
useEffect(() => {
if (monaco) {
let isReadOnly = fileName.startsWith("dist/");
monaco.editor.getEditors().forEach((e) =>
e.updateOptions({
readOnly: isReadOnly,
readOnlyMessage: {
value: "Generated files are read-only",
},
})
);
}
}, [fileName]);
useEffect(() => {
const root = document.querySelector(":root") as HTMLElement;
if (root) {
function reactToThemeChange() {
const selectedTheme = root.getAttribute("data-theme");
if (selectedTheme !== theme && selectedTheme !== null) {
setTheme(selectedTheme as "light" | "dark");
}
}
reactToThemeChange();
root.onchange = () => {
reactToThemeChange();
};
}
});
return (
<>
<main
@ -136,6 +166,7 @@ export default function Playground() {
}}
>
<Header
lang={lang.header}
onSave={onSave}
onReset={onReset}
onBuild={onBuild}
@ -146,8 +177,13 @@ export default function Playground() {
root={rootDir}
fileName={fileName}
setSelectedFileName={setFileName}
lang={lang.explorer}
/>
<Editor
fileName={fileName}
file={file ?? undefined}
theme={theme}
/>
<Editor fileName={fileName} file={file ?? undefined} />
</main>
</>
);

View File

@ -1,9 +1,17 @@
---
import type { Props } from "@astrojs/starlight/props";
import Default from "@astrojs/starlight/components/ContentPanel.astro";
import { isPlaygroundPage } from '@utils/playground';
const isPlayground = Astro.props.slug === 'playground';
const isPlayground = isPlaygroundPage(Astro.props.slug, Astro.currentLocale);
---
{isPlayground ? <slot /> : <Default {...Astro.props}><slot /></Default>}
{
isPlayground ? (
<slot />
) : (
<Default {...Astro.props}>
<slot />
</Default>
)
}

View File

@ -1,8 +1,9 @@
---
import type { Props } from "@astrojs/starlight/props";
import Default from "@astrojs/starlight/components/PageTitle.astro";
import { isPlaygroundPage } from '@utils/playground';
const isPlayground = Astro.props.slug === 'playground';
const isPlayground = isPlaygroundPage(Astro.props.slug, Astro.currentLocale);
---

View File

@ -1,17 +1,20 @@
import type { File } from "@components/Playground";
import type { File } from "@utils/playground";
import MonacoEditor, { useMonaco } from "@monaco-editor/react";
import { getHighlighter, type Highlighter } from "shiki";
import { shikiToMonaco } from "@shikijs/monaco";
import { useEffect, useState } from "react";
import darkPlus from "tm-themes/themes/dark-plus.json";
import lightPlus from "tm-themes/themes/light-plus.json";
import { shulkerscriptGrammar } from "@utils/shulkerscript-grammar";
import { mcfunctionGrammar } from "@utils/mcfunction-grammar";
export default function Editor({
theme,
fileName,
file,
}: {
theme: "light" | "dark";
fileName: string;
file?: File;
}) {
@ -22,9 +25,12 @@ export default function Editor({
if (monaco) {
if (highlighter == null) {
getHighlighter({
themes: [darkPlus as any],
themes: [darkPlus as any, lightPlus],
langs: ["toml", shulkerscriptGrammar, mcfunctionGrammar],
}).then((highlighter) => {
highlighter.setTheme(
theme === "dark" ? "dark-plus" : "light-plus"
);
setHighlighter(highlighter);
});
} else {
@ -43,14 +49,13 @@ export default function Editor({
}, [highlighter]);
return (
<div className="editor">
<MonacoEditor
height="70vh"
theme="vs-dark"
path={fileName}
defaultLanguage={file?.language}
defaultValue={file?.content}
/>
</div>
<MonacoEditor
height="70vh"
theme={theme === "dark" ? "dark-plus" : "light-plus"}
path={fileName}
defaultLanguage={file?.language}
defaultValue={file?.content}
wrapperProps={{ className: "editor" }}
/>
);
}

View File

@ -1,4 +1,4 @@
import type { Directory, SetState } from "@components/Playground";
import type { Directory, PlaygroundExplorerLang, SetState } from "@utils/playground";
import React, { useState } from "react";
import {
GoChevronDown as ChevDown,
@ -6,11 +6,13 @@ import {
} from "react-icons/go";
export default function FileView({
lang,
root,
fileName,
setSelectedFileName,
className,
}: {
lang: PlaygroundExplorerLang;
root: Directory;
fileName: string;
setSelectedFileName: SetState<string>;
@ -18,7 +20,7 @@ export default function FileView({
}) {
return (
<div className={className}>
<h3>Explorer</h3>
<h3>{lang.title}</h3>
<div className="entries">
{Object.entries(root.dirs ?? {}).map(([name, dir]) => {
return (

View File

@ -1,11 +1,14 @@
import type { PlaygroundHeaderLang } from "@utils/playground";
import DropdownButton from "./DropdownButton";
export default function Header({
lang,
onSave,
onReset,
onBuild,
onZip,
}: {
lang: PlaygroundHeaderLang;
onSave: () => void;
onReset: () => void;
onBuild: () => void;
@ -19,17 +22,17 @@ export default function Header({
marginBottom: "0.5cm",
}}
>
<h1 id="_top">Playground</h1>
<h1 id="_top">{lang.title}</h1>
<div className="buttons" style={{ height: "100%" }}>
<DropdownButton
style={{ height: "100%", marginRight: "0.5cm"}}
visible={[["Save", onSave]]}
options={[["Reset", onReset]]}
visible={[[lang.buttons.save, onSave]]}
options={[[lang.buttons.reset, onReset]]}
/>
<DropdownButton
style={{ height: "100%" }}
visible={[["Build", onBuild]]}
options={[["Download zip", onZip]]}
visible={[[lang.buttons.build, onBuild]]}
options={[[lang.buttons.zip, onZip]]}
/>
</div>
</header>

View File

@ -0,0 +1,30 @@
---
import PlaygroundComponent from "@components/Playground";
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
import type { PlaygroundLang } from "@utils/playground";
const lang: PlaygroundLang = {
header: {
title: "Spielplatz",
buttons: {
build: "Bauen",
zip: "Als Zip herunterladen",
save: "Speichern",
reset: "Zurücksetzen",
},
},
explorer: {
title: "Dateien",
},
};
---
<StarlightPage frontmatter={{ title: "Playground", template: "splash" }}>
<PlaygroundComponent client:only="react" {lang} />
</StarlightPage>
<style is:global>
.pagination-links {
display: none;
}
</style>

View File

@ -1,8 +1,30 @@
---
import PlaygroundComponent from '@components/Playground';
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import PlaygroundComponent from "@components/Playground";
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
import type { PlaygroundLang } from "@utils/playground";
const lang: PlaygroundLang = {
header: {
title: "Playground",
buttons: {
build: "Build",
zip: "Download zip",
save: "Save",
reset: "Reset",
},
},
explorer: {
title: "Explorer",
},
};
---
<StarlightPage frontmatter={{ title: 'Playground', template: "splash" }}>
<PlaygroundComponent client:only="react" />
<StarlightPage frontmatter={{ title: "Playground", template: "splash" }}>
<PlaygroundComponent client:only="react" {lang} />
</StarlightPage>
<style is:global>
.pagination-links {
display: none;
}
</style>

View File

@ -13,6 +13,8 @@
display: flex;
flex-direction: column;
margin-right: 0.5cm;
overflow-x: hidden;
overflow-y: auto;
.entries {
button {
@ -22,15 +24,15 @@
background-color: transparent;
cursor: pointer;
&:hover {
background-color: #444444;
}
&:disabled {
cursor: default;
color: var(--sl-color-text);
background-color: #333;
}
&:hover {
background-color: #444444;
}
}
}
}
@ -38,3 +40,15 @@
grid-area: editor;
}
}
:root[data-theme='light'] {
.playground > .file-view .entries button {
&:hover {
background-color: var(--sl-color-gray-5);
}
&:disabled {
background-color: var(--sl-color-gray-6);
}
}
}

33
src/utils/playground.ts Normal file
View File

@ -0,0 +1,33 @@
export type PlaygroundLang = {
header: PlaygroundHeaderLang;
explorer: PlaygroundExplorerLang;
};
export type PlaygroundHeaderLang = {
title: string;
buttons: {
save: string;
reset: string;
build: string;
zip: string;
};
};
export type PlaygroundExplorerLang = {
title: string;
};
export type File = {
language?: string;
content: string;
};
export type Directory = {
dirs?: { [key: string]: Directory };
files?: { [key: string]: File };
};
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
export function isPlaygroundPage(slug: string, lang?: string): boolean {
return (
slug === (!lang || lang == "en" ? "playground" : `${lang}/playground`)
);
}

View File

@ -62,7 +62,10 @@ impl From<VFolder> for Directory {
}
for (name, item) in value.get_files() {
files.insert(name.to_string(), item.clone().into());
files.insert(
name.to_string(),
File::from(item.clone()).correct_lang(name),
);
}
Self {
@ -84,3 +87,16 @@ impl From<VFile> for File {
}
}
}
impl File {
pub fn correct_lang(self, name: &str) -> Self {
let language = match name.split('.').last() {
Some("shu") => Some("shulkerscript".to_string()),
Some("mcfunction") => Some("mcfunction".to_string()),
Some("json" | "mcmeta") => Some("json".to_string()),
_ => None,
};
Self { language, ..self }
}
}

View File

@ -42,10 +42,10 @@ pub fn compile(root_dir: JsValue) -> JsValue {
/// Returns a base64 encoded zip file containing the compiled datapack.
#[wasm_bindgen(js_name = compileZip)]
pub fn compile_zip(root_dir: JsValue) -> String {
pub fn compile_zip(root_dir: JsValue) -> Option<String> {
let root_dir = VFolder::from(serde_wasm_bindgen::from_value::<Directory>(root_dir).unwrap());
let datapack = _compile(&root_dir).unwrap();
let datapack = _compile(&root_dir).ok()?;
let mut buffer = Cursor::new(Vec::new());
let mut writer = ZipWriter::new(&mut buffer);
@ -70,7 +70,7 @@ pub fn compile_zip(root_dir: JsValue) -> String {
writer.finish().unwrap();
BASE64_STANDARD.encode(buffer.into_inner())
Some(BASE64_STANDARD.encode(buffer.into_inner()))
}
fn _compile(root_dir: &VFolder) -> Result<VFolder> {