Compare commits
No commits in common. "8745f88c2c7ca2390523d16dae0894ec1d28d519" and "da9024fa647f2c4e6d1d1b52fc70fe149fef9bb0" have entirely different histories.
8745f88c2c
...
da9024fa64
11 changed files with 82 additions and 267 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "folio",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "folio",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.27.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "folio",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.4",
|
||||
"description": "A simple, local, open-source desktop writing app.",
|
||||
"productName": "Folio",
|
||||
"author": "Folio Contributors",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
saveMeta,
|
||||
Recent,
|
||||
} from "./project.js";
|
||||
import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js";
|
||||
import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js";
|
||||
import { buildMenu } from "./menu.js";
|
||||
import {
|
||||
createChapter,
|
||||
|
|
@ -363,20 +363,6 @@ handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => {
|
|||
return { ok: true, title };
|
||||
});
|
||||
|
||||
// Hide/unhide a recents entry. List-only: the book folder on disk is never
|
||||
// touched, and opening the book again un-hides it (addRecent clears the flag).
|
||||
handleIpc("folio:hideRecent", (_e, p: string) => {
|
||||
hideRecent(p);
|
||||
refreshMenu();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
handleIpc("folio:unhideRecent", (_e, p: string) => {
|
||||
unhideRecent(p);
|
||||
refreshMenu();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
handleIpc("folio:closeBook", () => {
|
||||
bookPath = null;
|
||||
mainWindow?.setTitle("Folio");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "path";
|
||||
import { app, Menu, BrowserWindow } from "electron";
|
||||
import { listVisibleRecents } from "./recents.js";
|
||||
import { listRecents } from "./recents.js";
|
||||
import type { Recent } from "./recents.js";
|
||||
|
||||
type MenuItem = Electron.MenuItemConstructorOptions;
|
||||
|
|
@ -14,7 +14,7 @@ export function buildMenu(
|
|||
onRequestOpen: () => void
|
||||
) {
|
||||
const isMac = process.platform === "darwin";
|
||||
const recents = listVisibleRecents();
|
||||
const recents = listRecents();
|
||||
|
||||
const recentItems: MenuItem[] = recents.length
|
||||
? recents.map((r: Recent) => {
|
||||
|
|
@ -63,11 +63,11 @@ export function buildMenu(
|
|||
accelerator: "CmdOrCtrl+R",
|
||||
// Always usable: falls back to the most recent valid book when none
|
||||
// is currently open, so the item never looks clickable yet do nothing.
|
||||
enabled: !!getState().bookPath || listVisibleRecents().some((r) => !r.missing),
|
||||
enabled: !!getState().bookPath || listRecents().some((r) => !r.missing),
|
||||
click: () => {
|
||||
const target =
|
||||
getState().bookPath ??
|
||||
listVisibleRecents().find((r) => !r.missing)?.path ??
|
||||
listRecents().find((r) => !r.missing)?.path ??
|
||||
null;
|
||||
if (!target) return;
|
||||
getWindow()?.webContents.send("folio:reveal-requested", target);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
||||
renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
|
||||
hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p),
|
||||
unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p),
|
||||
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ export interface Recent {
|
|||
path: string;
|
||||
title: string;
|
||||
lastOpened: string;
|
||||
// Hidden entries stay in the recents file (and the book on disk is
|
||||
// untouched) but are omitted from the visible recents list and menu.
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface BookResult {
|
||||
|
|
@ -124,10 +121,8 @@ export function listRecents(filePath: string): Recent[] {
|
|||
}
|
||||
|
||||
export function addRecent(filePath: string, recent: Recent): Recent[] {
|
||||
// Opening a book always surfaces it: a previously hidden entry is
|
||||
// un-hidden here, so re-opening is the natural way back.
|
||||
const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
|
||||
recents.unshift({ ...recent, hidden: false });
|
||||
recents.unshift(recent);
|
||||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
|
@ -145,22 +140,3 @@ export function renameRecent(filePath: string, recentPath: string, title: string
|
|||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
||||
// Hide/unhide are list-only operations: the entry stays in the recents file
|
||||
// and the book directory on disk is never touched. Hiding a path that is not
|
||||
// in the list is a no-op (there is nothing to hide).
|
||||
export function hideRecent(filePath: string, recentPath: string): Recent[] {
|
||||
const recents = listRecents(filePath).map((r) =>
|
||||
r.path === recentPath ? { ...r, hidden: true } : r
|
||||
);
|
||||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
||||
export function unhideRecent(filePath: string, recentPath: string): Recent[] {
|
||||
const recents = listRecents(filePath).map((r) =>
|
||||
r.path === recentPath ? { ...r, hidden: false } : r
|
||||
);
|
||||
writeJsonFile(filePath, recents);
|
||||
return recents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import {
|
|||
addRecent as _addRecent,
|
||||
removeRecent as _removeRecent,
|
||||
renameRecent as _renameRecent,
|
||||
hideRecent as _hideRecent,
|
||||
unhideRecent as _unhideRecent,
|
||||
} from "./project.js";
|
||||
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
||||
import type { Recent } from "./project.js";
|
||||
|
|
@ -62,8 +60,6 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
|
|||
// Keep every saved entry so a book never "disappears" from the list just
|
||||
// because its folder moved or was renamed. Mark entries whose book can't be
|
||||
// found so the UI can show them as missing instead of hiding them.
|
||||
// Hidden entries are returned too (flagged) — the UI decides visibility so
|
||||
// it can offer a "Hidden (n)" disclosure to bring them back.
|
||||
// Dedupe by PATH: keying on title used to hide genuinely distinct books
|
||||
// that happened to share a display name ("Untitled"), making one of them
|
||||
// unreachable from the recents menu.
|
||||
|
|
@ -78,12 +74,6 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
|
|||
return deduped;
|
||||
}
|
||||
|
||||
// Entries shown in the Open Recent menu and the welcome Recents list:
|
||||
// everything except explicitly hidden ones.
|
||||
export function listVisibleRecents(): (Recent & { missing?: boolean })[] {
|
||||
return listRecents().filter((r) => !r.hidden);
|
||||
}
|
||||
|
||||
export function addRecent(recent: Recent): Recent[] {
|
||||
return _addRecent(recentsFilePath(), recent);
|
||||
}
|
||||
|
|
@ -95,11 +85,3 @@ export function removeRecent(recentPath: string): Recent[] {
|
|||
export function renameRecent(recentPath: string, title: string): Recent[] {
|
||||
return _renameRecent(recentsFilePath(), recentPath, title);
|
||||
}
|
||||
|
||||
export function hideRecent(recentPath: string): Recent[] {
|
||||
return _hideRecent(recentsFilePath(), recentPath);
|
||||
}
|
||||
|
||||
export function unhideRecent(recentPath: string): Recent[] {
|
||||
return _unhideRecent(recentsFilePath(), recentPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ export interface Recent {
|
|||
path: string;
|
||||
title: string;
|
||||
lastOpened: string;
|
||||
hidden?: boolean;
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenedBook {
|
||||
|
|
@ -41,8 +39,6 @@ export type FolioAPI = {
|
|||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
renameBook: (p: string, title: string) => Promise<any>;
|
||||
hideRecent: (p: string) => Promise<any>;
|
||||
unhideRecent: (p: string) => Promise<any>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
getSpellcheck: () => Promise<boolean>;
|
||||
setSpellcheck: (on: boolean) => Promise<any>;
|
||||
|
|
|
|||
|
|
@ -185,155 +185,91 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
try {
|
||||
const recents = await api.getRecents();
|
||||
recentList.innerHTML = "";
|
||||
const visible = (recents as (Recent & { missing?: boolean })[]).filter(
|
||||
(r) => !r.hidden
|
||||
);
|
||||
const hidden = (recents as (Recent & { missing?: boolean })[]).filter(
|
||||
(r) => r.hidden
|
||||
);
|
||||
if (!visible.length && !hidden.length) {
|
||||
if (!recents.length) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "muted";
|
||||
li.textContent = "No recent books.";
|
||||
recentList.appendChild(li);
|
||||
return;
|
||||
}
|
||||
for (const r of visible) {
|
||||
recentList.appendChild(recentRow(r, false));
|
||||
}
|
||||
if (hidden.length) {
|
||||
recentList.appendChild(hiddenDisclosure(hidden));
|
||||
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "recent-item";
|
||||
li.tabIndex = 0;
|
||||
const label = document.createElement("span");
|
||||
label.className = "recent-title";
|
||||
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
||||
label.title = r.path;
|
||||
// Single click selects the row (reveals Rename/Delete actions);
|
||||
// double-click or Enter opens the book. Replaces the old always-on
|
||||
// "×" button, which deleted on a mis-click.
|
||||
label.addEventListener("click", () => selectRecent(li));
|
||||
li.addEventListener("dblclick", () => void openPath(r.path));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") void openPath(r.path);
|
||||
else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li);
|
||||
});
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "recent-actions";
|
||||
if (!r.missing) {
|
||||
const ren = document.createElement("button");
|
||||
ren.className = "recent-action icon-button";
|
||||
ren.type = "button";
|
||||
ren.title = "Rename book";
|
||||
ren.setAttribute("aria-label", "Rename book");
|
||||
ren.textContent = "✎";
|
||||
ren.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const name = await promptInput("Rename book", r.title || "");
|
||||
if (name === null) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === r.title) return;
|
||||
const res = await api.renameBook(r.path, trimmed);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not rename", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
actions.appendChild(ren);
|
||||
}
|
||||
const del = document.createElement("button");
|
||||
del.className = "recent-action icon-button recent-delete";
|
||||
del.type = "button";
|
||||
del.title = "Delete book";
|
||||
del.setAttribute("aria-label", "Delete book");
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const res = await api.deleteBook(r.path);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not delete", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
// Only promise an in-app Undo where restore actually works (Linux
|
||||
// home trash). Elsewhere the book sits in the OS trash, which the
|
||||
// user can restore manually — no false "Undo" button.
|
||||
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
|
||||
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
|
||||
const rr = await api.restoreBook(r.path);
|
||||
if (rr && "error" in rr) {
|
||||
await alertMessage("Could not restore", (rr as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
});
|
||||
actions.appendChild(del);
|
||||
li.appendChild(label);
|
||||
li.appendChild(actions);
|
||||
recentList.appendChild(li);
|
||||
}
|
||||
} catch {
|
||||
// Recents are best-effort; never block the welcome screen.
|
||||
}
|
||||
}
|
||||
|
||||
function recentRow(r: Recent & { missing?: boolean }, isHidden: boolean): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "recent-item" + (isHidden ? " recent-hidden-entry" : "");
|
||||
li.tabIndex = 0;
|
||||
const label = document.createElement("span");
|
||||
label.className = "recent-title";
|
||||
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
||||
label.title = r.path;
|
||||
// Single click selects the row (reveals Rename/Hide/Delete actions);
|
||||
// double-click or Enter opens the book. Replaces the old always-on
|
||||
// "×" button, which deleted on a mis-click.
|
||||
label.addEventListener("click", () => selectRecent(li));
|
||||
li.addEventListener("dblclick", () => void openPath(r.path));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") void openPath(r.path);
|
||||
else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li);
|
||||
});
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "recent-actions";
|
||||
if (!r.missing) {
|
||||
const ren = document.createElement("button");
|
||||
ren.className = "recent-action icon-button";
|
||||
ren.type = "button";
|
||||
ren.title = "Rename book";
|
||||
ren.setAttribute("aria-label", "Rename book");
|
||||
ren.textContent = "✎";
|
||||
ren.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const name = await promptInput("Rename book", r.title || "");
|
||||
if (name === null) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === r.title) return;
|
||||
const res = await api.renameBook(r.path, trimmed);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not rename", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
actions.appendChild(ren);
|
||||
}
|
||||
if (isHidden) {
|
||||
const unhide = document.createElement("button");
|
||||
unhide.className = "recent-action icon-button recent-hide";
|
||||
unhide.type = "button";
|
||||
unhide.title = "Show in recents (book on disk is untouched)";
|
||||
unhide.setAttribute("aria-label", "Unhide book in recents");
|
||||
unhide.textContent = "Unhide";
|
||||
unhide.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.unhideRecent(r.path);
|
||||
await renderRecents();
|
||||
});
|
||||
actions.appendChild(unhide);
|
||||
} else {
|
||||
// Hide is list-only — safe by design, so it needs no confirmation and
|
||||
// sits BEFORE the destructive Delete so a mis-click lands on the safe one.
|
||||
const hide = document.createElement("button");
|
||||
hide.className = "recent-action icon-button recent-hide";
|
||||
hide.type = "button";
|
||||
hide.title = "Hide from recents (book on disk is untouched)";
|
||||
hide.setAttribute("aria-label", "Hide from recents");
|
||||
hide.textContent = "Hide";
|
||||
hide.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.hideRecent(r.path);
|
||||
await renderRecents();
|
||||
});
|
||||
actions.appendChild(hide);
|
||||
}
|
||||
const del = document.createElement("button");
|
||||
del.className = "recent-action icon-button recent-delete";
|
||||
del.type = "button";
|
||||
del.title = "Delete book";
|
||||
del.setAttribute("aria-label", "Delete book");
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const res = await api.deleteBook(r.path);
|
||||
if (res && "error" in res) {
|
||||
await alertMessage("Could not delete", (res as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
// Only promise an in-app Undo where restore actually works (Linux
|
||||
// home trash). Elsewhere the book sits in the OS trash, which the
|
||||
// user can restore manually — no false "Undo" button.
|
||||
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
|
||||
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
|
||||
const rr = await api.restoreBook(r.path);
|
||||
if (rr && "error" in rr) {
|
||||
await alertMessage("Could not restore", (rr as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
await renderRecents();
|
||||
});
|
||||
});
|
||||
actions.appendChild(del);
|
||||
li.appendChild(label);
|
||||
li.appendChild(actions);
|
||||
return li;
|
||||
}
|
||||
|
||||
// Collapsible "Hidden (n)" row: click to expand the hidden entries inline,
|
||||
// each offering Unhide. Hidden books stay reachable without polluting the
|
||||
// main list.
|
||||
function hiddenDisclosure(hidden: (Recent & { missing?: boolean })[]): HTMLLIElement {
|
||||
const wrap = document.createElement("li");
|
||||
wrap.className = "recent-hidden-group";
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "recent-hidden-toggle";
|
||||
toggle.textContent = `Hidden (${hidden.length})`;
|
||||
const sub = document.createElement("ul");
|
||||
sub.className = "recent-list recent-hidden-items hidden";
|
||||
for (const r of hidden) sub.appendChild(recentRow(r, true));
|
||||
toggle.addEventListener("click", () => {
|
||||
sub.classList.toggle("hidden");
|
||||
toggle.classList.toggle("open");
|
||||
});
|
||||
wrap.appendChild(toggle);
|
||||
wrap.appendChild(sub);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// At most one recents row is selected at a time; selection reveals the
|
||||
// row's action buttons (see .recent-actions CSS).
|
||||
function deselectRecent(li: HTMLElement) {
|
||||
|
|
|
|||
|
|
@ -665,50 +665,6 @@ html, body {
|
|||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Hide is list-only, so it reads quieter than the destructive Delete. */
|
||||
.recent-item .recent-hide:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
/* "Hidden (n)" disclosure at the bottom of the recents list. */
|
||||
.recent-hidden-group {
|
||||
list-style: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.recent-hidden-toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.recent-hidden-toggle:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.recent-hidden-toggle::before {
|
||||
content: "▸ ";
|
||||
}
|
||||
|
||||
.recent-hidden-toggle.open::before {
|
||||
content: "▾ ";
|
||||
}
|
||||
|
||||
.recent-hidden-items {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.recent-hidden-items .recent-title {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ const {
|
|||
hasBook,
|
||||
listRecents,
|
||||
addRecent,
|
||||
hideRecent,
|
||||
unhideRecent,
|
||||
makeChapterId,
|
||||
chapterFileName,
|
||||
} = mod;
|
||||
|
|
@ -67,19 +65,6 @@ ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top");
|
|||
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
|
||||
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
|
||||
|
||||
// Hide/unhide: list-only — entry stays in the file, disk untouched.
|
||||
const h1 = hideRecent(rf, "/other");
|
||||
ok(h1.find((r) => r.path === "/other")?.hidden === true, "hide marks entry hidden");
|
||||
ok(h1.length === 2, "hide keeps the entry in the file");
|
||||
ok(fs.existsSync(path.join(dir, "folio.json")), "hide leaves book on disk intact");
|
||||
const h2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-04-04T00:00:00Z" });
|
||||
ok(h2.find((r) => r.path === "/other")?.hidden === false, "re-opening un-hides");
|
||||
const h3 = hideRecent(rf, "/other");
|
||||
const h4 = unhideRecent(rf, "/other");
|
||||
ok(h4.find((r) => r.path === "/other")?.hidden === false, "unhide clears the flag");
|
||||
ok(h3.length === 2 && h4.length === 2, "hide/unhide never drop entries");
|
||||
ok(hideRecent(rf, "/nowhere").length === 2, "hide of unknown path is a no-op");
|
||||
|
||||
const id = makeChapterId("Chapter One");
|
||||
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
|
||||
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue