diff --git a/.dockerignore b/.dockerignore index d6e75b1..00ee9d5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,8 @@ web/dist CONTEXT.md docs .dev-data -deploy/data +deploy/data/* +!deploy/data/vault +deploy/data/vault/**/.rawmark +deploy/data/**/*.db* +deploy/data/**/*.log diff --git a/.gitignore b/.gitignore index b77ee22..c771088 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,15 @@ web/dist/ # Local development data .dev-data/ -deploy/data/ + +# Public examples are tracked; instance state must remain outside the template. +deploy/data/* +!deploy/data/vault/ +deploy/data/vault/**/.rawmark/ +deploy/data/**/*.db +deploy/data/**/*.db-wal +deploy/data/**/*.db-shm +deploy/data/**/*.log # Repository-maintained tool rules (other local skills stay untracked) .agents/skills/* diff --git a/Dockerfile b/Dockerfile index 377daa1..f4060a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd COPY internal ./internal +COPY deploy/embed.go ./deploy/ +COPY deploy/data/vault ./deploy/data/vault COPY web/embed.go web/font_storage.go ./web/ COPY --from=web /src/web/dist ./web/dist RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/rawmark ./cmd/rawmark && mkdir -p /out/data diff --git a/README.md b/README.md index c14989e..1155267 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ docker run -d --name rawmark --restart unless-stopped \ rawmark ``` -Open **http://localhost:21000**, set your password on first use, and create your first note. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume. +Open **http://localhost:21000** and set your password on first use. A new, empty instance starts with seven English example notes; edit or delete them and begin writing. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume. These commands listen on localhost only. For a remote server, complete the initial setup before configuring HTTPS access. See the [deployment guide](docs/deployment.md) for Docker Compose, existing note folders, backups, and upgrades. diff --git a/README.zh-CN.md b/README.zh-CN.md index b4f4625..3bd573f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -48,7 +48,7 @@ docker run -d --name rawmark --restart unless-stopped \ rawmark ``` -打开 **http://localhost:21000**,首次访问设置登录密码,即可创建第一篇笔记。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 +打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 以上命令仅监听本机。部署到服务器时,请先完成初始化,再配置 HTTPS 访问;Docker Compose、已有笔记目录接入、备份与升级见[部署文档](docs/deployment.zh-CN.md)。 diff --git a/cmd/rawmark/examples.go b/cmd/rawmark/examples.go new file mode 100644 index 0000000..49f7cd3 --- /dev/null +++ b/cmd/rawmark/examples.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "io/fs" + "rawmark/deploy" + "rawmark/internal/auth" + "rawmark/internal/store" + "rawmark/internal/vault" +) + +// Keep bootstrap state out of user preferences so resetting settings cannot restore deleted examples. +const examplesSetting = "bootstrap.examples" + +func initializeExamples(v *vault.Vault, st *store.Store) error { + _, done, err := st.Setting(examplesSetting) + if err != nil || done { + return err + } + setUp, err := auth.New(st).IsSetUp() + if err != nil { + return err + } + if !setUp { + examples, err := fs.Sub(deploy.Examples, "data/vault") + if err != nil { + return err + } + if err := v.SeedExamples(examples); err != nil { + return fmt.Errorf("初始化示例笔记失败: %w", err) + } + } + return st.SetSetting(examplesSetting, "1") +} diff --git a/cmd/rawmark/examples_test.go b/cmd/rawmark/examples_test.go new file mode 100644 index 0000000..d1a66a4 --- /dev/null +++ b/cmd/rawmark/examples_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "rawmark/deploy" + "rawmark/internal/auth" + "rawmark/internal/store" + "rawmark/internal/vault" + "testing" +) + +func TestInitializeExamplesOnlyOnce(t *testing.T) { + data := t.TempDir() + v, err := vault.Open(filepath.Join(data, "vault")) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(data, "rawmark.db")) + if err != nil { + t.Fatal(err) + } + if err := initializeExamples(v, st); err != nil { + t.Fatal(err) + } + if ready, err := auth.New(st).IsSetUp(); err != nil || ready { + t.Fatalf("examples must not initialize credentials: %v, %v", ready, err) + } + count := 0 + if err := fs.WalkDir(deploy.Examples, "data/vault", func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if filepath.Ext(name) != ".md" { + t.Fatalf("unexpected template file: %s", name) + } + count++ + rel, err := filepath.Rel("data/vault", name) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(v.Root(), rel)); err != nil { + return err + } + return nil + }); err != nil { + t.Fatal(err) + } + if count != 7 { + t.Fatalf("expected seven public sample notes, got %d", count) + } + // Deletion before password setup, settings reset, and process restart must not restore examples. + entries, err := os.ReadDir(v.Root()) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Name() != ".rawmark" { + if err := os.RemoveAll(filepath.Join(v.Root(), entry.Name())); err != nil { + t.Fatal(err) + } + } + } + if err := st.ResetPreferences(); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = store.Open(filepath.Join(data, "rawmark.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := initializeExamples(v, st); err != nil { + t.Fatal(err) + } + entries, err = os.ReadDir(v.Root()) + if err != nil || len(entries) != 1 || entries[0].Name() != ".rawmark" { + t.Fatalf("deleted examples were restored: %v, %v", entries, err) + } +} + +func TestInitializeExamplesSkipsInitializedEmptyInstance(t *testing.T) { + data := t.TempDir() + v, err := vault.Open(filepath.Join(data, "vault")) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(data, "rawmark.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := auth.New(st).Setup("synthetic-test-password"); err != nil { + t.Fatal(err) + } + if err := initializeExamples(v, st); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(v.Root()) + if err != nil || len(entries) != 1 || entries[0].Name() != ".rawmark" { + t.Fatalf("examples were added to an existing account: %v, %v", entries, err) + } +} diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 60345e3..4511879 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -129,6 +129,9 @@ func serve(parent context.Context, addr, dataDir, vaultDir, version string, font return false, err } defer st.Close() + if err := initializeExamples(v, st); err != nil { + return false, err + } idx, err := search.Open(filepath.Join(dataDir, "index.db"), v) if err != nil { return false, err diff --git a/deploy/data/vault/Getting Started/Markdown Guide.md b/deploy/data/vault/Getting Started/Markdown Guide.md new file mode 100644 index 0000000..a25d575 --- /dev/null +++ b/deploy/data/vault/Getting Started/Markdown Guide.md @@ -0,0 +1,13 @@ +# Markdown Guide + +Start with headings, lists, and links. + +## Headings + +Use `#` to organize your notes. + +## Lists + +- An idea +- A plan +- A next step diff --git a/deploy/data/vault/Getting Started/Welcome to RawMark.md b/deploy/data/vault/Getting Started/Welcome to RawMark.md new file mode 100644 index 0000000..f56fed9 --- /dev/null +++ b/deploy/data/vault/Getting Started/Welcome to RawMark.md @@ -0,0 +1,25 @@ +# A space of your own. + +Capture ideas. Connect thoughts. Keep writing with **RawMark**, your personal Markdown workspace. + +> Your notes. Your files. Your space. + +- [x] Create a home for your knowledge +- [ ] Write down your next idea + +## Familiar Markdown + +Keep **bold** ideas, *small details*, and ==highlights== in one place. + +| What to capture | Where it belongs | Keep it moving | +| --- | --- | --- | +| Project plans | Projects | Take the next step | +| Reading notes | Knowledge Base | Connect new ideas | + +## Keep ideas growing + +```typescript +const notes = await collectIdeas() +const garden = notes.map(idea => idea.grow()) +console.log('Keep writing, keep growing.') +``` diff --git a/deploy/data/vault/Ideas/Things to Try.md b/deploy/data/vault/Ideas/Things to Try.md new file mode 100644 index 0000000..aa5beec --- /dev/null +++ b/deploy/data/vault/Ideas/Things to Try.md @@ -0,0 +1,5 @@ +# Things to Try + +- Document a journey +- Add illustrations to an old note +- Revisit the knowledge base each week diff --git a/deploy/data/vault/Knowledge Base/Learning Notes.md b/deploy/data/vault/Knowledge Base/Learning Notes.md new file mode 100644 index 0000000..3c01ccb --- /dev/null +++ b/deploy/data/vault/Knowledge Base/Learning Notes.md @@ -0,0 +1,3 @@ +# Learning Notes + +Explain what you learn in your own words. diff --git a/deploy/data/vault/Knowledge Base/Reading List.md b/deploy/data/vault/Knowledge Base/Reading List.md new file mode 100644 index 0000000..f95ad1d --- /dev/null +++ b/deploy/data/vault/Knowledge Base/Reading List.md @@ -0,0 +1,5 @@ +# Reading List + +- Design and everyday life +- Software and ideas +- The joy of writing diff --git a/deploy/data/vault/Projects/Product Design.md b/deploy/data/vault/Projects/Product Design.md new file mode 100644 index 0000000..fe05f2a --- /dev/null +++ b/deploy/data/vault/Projects/Product Design.md @@ -0,0 +1,7 @@ +# Product Design + +Make every note feel simple and natural. + +## Principles + +Less distraction. More focus. diff --git a/deploy/data/vault/Projects/Weekly Plan.md b/deploy/data/vault/Projects/Weekly Plan.md new file mode 100644 index 0000000..8f74fc2 --- /dev/null +++ b/deploy/data/vault/Projects/Weekly Plan.md @@ -0,0 +1,7 @@ +# Weekly Plan + +## In progress + +- [x] Organize project notes +- [ ] Draft the first proposal +- [ ] Make time for focused writing diff --git a/deploy/embed.go b/deploy/embed.go new file mode 100644 index 0000000..1d65c4e --- /dev/null +++ b/deploy/embed.go @@ -0,0 +1,9 @@ +// Package deploy embeds the public example vault used by new instances. +package deploy + +import "embed" + +// Examples is the single source of the example files shipped in every binary. +// +//go:embed data/vault +var Examples embed.FS diff --git a/docs/deployment.md b/docs/deployment.md index a65c0b1..ba88aeb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,16 +28,26 @@ ssh -L 21000:127.0.0.1:21000 user@your-server The container defaults to UID/GID `65532:65532`. A new Docker named volume inherits permissions from the image's data directory. +A fresh instance with an empty vault receives seven English example notes from the public template in `deploy/data/vault/`. They are embedded in the binary, so Docker and native deployments behave the same way. You still set your own password; no account credentials or personal settings are shipped. Existing accounts and nonempty vaults are left untouched. Examples are ordinary user files: deleting them, restarting, or resetting preferences does not recreate them. Initialization state is retained in the application database, outside user preferences. + ## Docker Compose -The repository includes [deploy/docker-compose.yml](../deploy/docker-compose.yml), which persists data in the host's `deploy/data/`. For a first deployment on Linux: +The repository includes [deploy/docker-compose.yml](../deploy/docker-compose.yml), which stores runtime data in an independent Docker named volume: ```sh -mkdir -p deploy/data -sudo chown 65532:65532 deploy/data docker compose -f deploy/docker-compose.yml up -d --build --wait ``` +The tracked `deploy/data/` directory is a public example template, not a runtime data directory. Do not mount it as writable application storage. To use a host directory instead, create it with UID/GID `65532:65532` and set an absolute path: + +```sh +sudo install -d -o 65532 -g 65532 /your/storage/rawmark/data +RAWMARK_DATA_DIR=/your/storage/rawmark/data \ + docker compose -f deploy/docker-compose.yml up -d --build --wait +``` + +Keep the same `RAWMARK_DATA_DIR` value for every subsequent Compose command, for example in a private file passed with `--env-file`. This Compose variable selects the host mount; it is separate from the application's `RAWMARK_DATA` variable inside the container. Existing deployments that used `deploy/data/` must explicitly select that directory until it has been migrated; omitting the override starts a separate instance in a named volume. + This configuration publishes port **21000** on all network interfaces by default. Complete initialization and configure access boundaries before exposing it publicly. With a reverse proxy, you can change the Compose port binding to `127.0.0.1:${RAWMARK_PORT:-21000}:8080`. For a custom port, use `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`. Do not point the quick-start container and a Compose instance at the same database or vault concurrently. Stop the old instance, back up, and verify data locations before switching deployment methods. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index 4a1ec74..0b48a3a 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -28,16 +28,26 @@ ssh -L 21000:127.0.0.1:21000 user@your-server 容器默认以 UID/GID `65532:65532` 运行,Docker 新建命名卷会继承镜像数据目录的权限。 +全新实例的空笔记库会自动生成 `deploy/data/vault/` 公开模板中的 7 篇英文示例笔记。示例嵌入二进制,Docker 与原生部署行为一致。仍需自行设置密码,不附带账号凭据或私人设置;已有账号和非空笔记库不会被添加示例。示例是普通文件,删除、重启或重置偏好设置后都不会重新生成;一次性初始化状态保存在应用数据库中,不属于用户偏好设置。 + ## Docker Compose -仓库提供 [deploy/docker-compose.yml](../deploy/docker-compose.yml),使用宿主机的 `deploy/data/` 持久化。Linux 上首次部署可执行: +仓库提供 [deploy/docker-compose.yml](../deploy/docker-compose.yml),默认使用独立的 Docker 命名卷保存运行数据: ```sh -mkdir -p deploy/data -sudo chown 65532:65532 deploy/data docker compose -f deploy/docker-compose.yml up -d --build --wait ``` +纳入版本控制的 `deploy/data/` 是公开示例模板,不是运行数据目录,不要将其作为可写数据目录挂载。若使用宿主机目录,先创建目录并设置 UID/GID `65532:65532`,再指定绝对路径: + +```sh +sudo install -d -o 65532 -g 65532 /your/storage/rawmark/data +RAWMARK_DATA_DIR=/your/storage/rawmark/data \ + docker compose -f deploy/docker-compose.yml up -d --build --wait +``` + +后续每次执行 Compose 命令都必须使用相同的 `RAWMARK_DATA_DIR`,例如保存在通过 `--env-file` 指定的私有文件中。该 Compose 变量选择宿主机挂载位置,与容器内应用使用的 `RAWMARK_DATA` 不同。原先使用 `deploy/data/` 的实例,在迁出前必须显式指定原目录;省略此覆盖值会在命名卷中启动另一个独立实例。 + 该配置默认发布到所有网卡的 **21000** 端口。对公网开放前完成初始化并设置访问边界;使用反向代理时,可将 Compose 端口绑定改为 `127.0.0.1:${RAWMARK_PORT:-21000}:8080`。自定义端口可使用 `RAWMARK_PORT=21001 docker compose -f deploy/docker-compose.yml up -d --build --wait`。 不要把快速开始中的容器与 Compose 实例同时指向同一份数据库或笔记库;切换部署方式前先停止旧实例、备份并确认数据位置。 diff --git a/docs/development.md b/docs/development.md index e6184b7..ba83885 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,7 +53,7 @@ web/ router/ Routes and guards pinia/ Pinia instance and shared auth, permission, settings state types/auto/ Generated import declarations -deploy/ Container and proxy configuration +deploy/ Container/proxy configuration and embedded public example vault scripts/ Development, resource generation, historical migrations ``` diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index ea397d8..3b86d57 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -53,7 +53,7 @@ web/ router/ 路由与守卫 pinia/ 实例及共享认证、权限、设置状态 types/auto/ 自动生成的导入声明 -deploy/ 容器与代理配置 +deploy/ 容器、代理配置及嵌入的公开示例笔记库 scripts/ 开发、资源生成及历史迁移脚本 ``` diff --git a/docs/guide.md b/docs/guide.md index 2af2bcb..6e8b14d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -8,7 +8,7 @@ RawMark is a single-user workspace with notes stored on your server. Multiple br ## Start writing -Set a password on first use. Subsequent sign-ins require your password and an image captcha. Right-click the **Files** heading or empty space in the sidebar to search, create notes or folders, import documents, open the tag index, or manage images. You can also click the heading to open the menu on a touchscreen. +Set a password on first use. A new, empty instance includes seven English example notes that you can edit or delete; they do not return after deletion or restart. Existing vaults are not populated with examples. Subsequent sign-ins require your password and an image captcha. Right-click the **Files** heading or empty space in the sidebar to search, create notes or folders, import documents, open the tag index, or manage images. You can also click the heading to open the menu on a touchscreen. The editor opens in live preview mode. Use the icons at the top right to switch to source mode, open the outline, or enter focus mode. Supported syntax includes standard Markdown, tables, task lists, code blocks, math, Mermaid, footnotes, and YAML metadata. diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index 53c90d4..2ba422b 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -8,7 +8,7 @@ RawMark 是单用户笔记工作台,笔记保存在服务器上。多个浏览 ## 开始写作 -首次访问设置密码,之后通过密码和图片验证码登录。在侧栏「文件目录」标题或空白处右键,可以搜索、新建笔记、新建目录、导入文档、打开标签索引或管理图片;点击标题同样可以打开菜单,便于触屏使用。 +首次访问设置密码。全新空实例带有 7 篇英文示例笔记,可以编辑或删除;删除或重启后不会再次生成,已有笔记库不会添加示例。之后通过密码和图片验证码登录。在侧栏「文件目录」标题或空白处右键,可以搜索、新建笔记、新建目录、导入文档、打开标签索引或管理图片;点击标题同样可以打开菜单,便于触屏使用。 编辑器默认使用即时预览,点击右上角图标切换源码模式、打开大纲或进入专注写作。支持标准 Markdown、表格、任务列表、代码块、数学公式、Mermaid、脚注和 YAML 元数据。 diff --git a/internal/vault/examples.go b/internal/vault/examples.go new file mode 100644 index 0000000..9a17c1c --- /dev/null +++ b/internal/vault/examples.go @@ -0,0 +1,72 @@ +package vault + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +// SeedExamples populates only a completely unused vault, before the watcher starts. +// Existing folders, hidden files, icons, or recycled notes count as user data. +func (v *Vault) SeedExamples(examples fs.FS) (err error) { + v.mu.Lock() + defer v.mu.Unlock() + + entries, err := os.ReadDir(v.root) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() != metaDir || !entry.IsDir() { + return nil + } + } + metadata, err := os.ReadDir(filepath.Join(v.root, metaDir)) + if err != nil { + return err + } + for _, entry := range metadata { + if entry.Name() != "trash" || !entry.IsDir() { + return nil + } + } + trash, err := os.ReadDir(v.trash) + if err != nil || len(trash) != 0 { + return err + } + + stage, err := os.MkdirTemp(v.root, tmpPrefix+"examples-") + if err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + err = errors.Join(err, os.RemoveAll(stage)) + } + }() + if err = os.CopyFS(stage, examples); err != nil { + return err + } + entries, err = os.ReadDir(stage) + if err != nil { + return err + } + var published []string + for _, entry := range entries { + name := entry.Name() + if err = os.Rename(filepath.Join(stage, name), filepath.Join(v.root, name)); err != nil { + for _, previous := range published { + if rollback := os.Rename(filepath.Join(v.root, previous), filepath.Join(stage, previous)); rollback != nil { + // Preserve the staging directory if it contains the only copy of any files. + cleanup = false + err = errors.Join(err, rollback) + } + } + return err + } + published = append(published, name) + } + return nil +} diff --git a/internal/vault/examples_test.go b/internal/vault/examples_test.go new file mode 100644 index 0000000..3e085f5 --- /dev/null +++ b/internal/vault/examples_test.go @@ -0,0 +1,98 @@ +package vault + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestSeedExamplesPreservesHierarchyAndAttachments(t *testing.T) { + v := newVault(t) + source := fstest.MapFS{ + "Guide/Welcome.md": {Data: []byte("# Welcome\n\n![Example](./assets/image.png)\n")}, + "Guide/assets/image.png": {Data: []byte("example-image")}, + } + if err := v.SeedExamples(source); err != nil { + t.Fatal(err) + } + for name, original := range source { + data, err := os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(name))) + if err != nil || string(data) != string(original.Data) { + t.Fatalf("example %s: %q, %v", name, data, err) + } + } + if err := v.SeedExamples(fstest.MapFS{"Guide/Welcome.md": {Data: []byte("replacement")}}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(v.Root(), "Guide/Welcome.md")) + if err != nil || string(data) != string(source["Guide/Welcome.md"].Data) { + t.Fatalf("existing content changed: %q, %v", data, err) + } +} + +func TestSeedExamplesSkipsAnyExistingData(t *testing.T) { + for _, name := range []string{"Personal.md", "empty-folder/", ".hidden", ".rawmark/tree-icons.json", ".rawmark/trash/deleted/note.md"} { + t.Run(name, func(t *testing.T) { + v := newVault(t) + dest := filepath.Join(v.Root(), filepath.FromSlash(name)) + if strings.HasSuffix(name, "/") { + if err := os.MkdirAll(dest, 0755); err != nil { + t.Fatal(err) + } + } else { + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dest, []byte("private"), 0600); err != nil { + t.Fatal(err) + } + } + if err := v.SeedExamples(fstest.MapFS{"Example.md": {Data: []byte("public")}}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "Example.md")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("examples were added to a used vault: %v", err) + } + if !strings.HasSuffix(name, "/") { + data, err := os.ReadFile(dest) + if err != nil || string(data) != "private" { + t.Fatalf("existing content changed: %q, %v", data, err) + } + } + }) + } +} + +type unreadableExampleFS struct{ fs.FS } + +func (f unreadableExampleFS) Open(name string) (fs.File, error) { + if name == "Guide/Note.md" { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission} + } + return f.FS.Open(name) +} + +func TestSeedExamplesFailureDoesNotPublishPartialVault(t *testing.T) { + v := newVault(t) + source := unreadableExampleFS{fstest.MapFS{ + "First.md": {Data: []byte("staged first")}, + "Guide/Note.md": {Data: []byte("unreadable")}, + }} + if err := v.SeedExamples(source); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("original failure was lost: %v", err) + } + entries, err := os.ReadDir(v.Root()) + if err != nil || len(entries) != 1 || entries[0].Name() != metaDir { + t.Fatalf("partial examples or temporary files remain: %v, %v", entries, err) + } + if err := v.SeedExamples(fstest.MapFS{"Welcome.md": {Data: []byte("retry")}}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "Welcome.md")); err != nil { + t.Fatalf("retry failed: %v", err) + } +}