- deriveLinks strips fenced code blocks so markdown examples don't produce phantom link completions - Add resolveContentEntry returning body + path + inline flag; scanDocDirectives uses it instead of re-deriving resolution, and parseDirectiveAttrs is replaced by the shared parseBlockAttrs - Fix relative file refs with ./ prefix never resolving through resolveContent (pathIndex lookup joined the ./ verbatim)
629 lines
20 KiB
TypeScript
629 lines
20 KiB
TypeScript
/**
|
|
* Content registry — the single source of truth for all content in a
|
|
* TTRPG Tools project.
|
|
*
|
|
* Two stores:
|
|
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
|
|
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
|
|
* a stable id and owned by that doc.
|
|
*
|
|
* Everything structured (completions, declarations, tag modifiers) is a
|
|
* *derived* view over this registry — see `deriveCompletions`.
|
|
*
|
|
* This module is browser-safe (no Node-only imports) so the CLI and the
|
|
* frontend share one implementation. The filesystem walk lives in the CLI
|
|
* (`buildRegistry` in `commands/serve.ts`).
|
|
*/
|
|
|
|
import Slugger from "github-slugger";
|
|
import {
|
|
parseDeclareCsv,
|
|
type VarDeclaration,
|
|
type TagModifier,
|
|
} from "./completions/declare-parser.js";
|
|
import {
|
|
FENCED_BLOCK_RE,
|
|
parseBlockAttrs,
|
|
} from "./completions/block-scanner.js";
|
|
import type {
|
|
CompletionsPayload,
|
|
DiceCompletion,
|
|
LinkCompletion,
|
|
SparkTableCompletion,
|
|
} from "./completions/types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ContentKind = "csv" | "text" | "declare";
|
|
|
|
/** A single piece of inline content defined inside a doc. */
|
|
export interface DocContent {
|
|
/** Stable id — author-supplied or derived from the body. */
|
|
id: string;
|
|
kind: ContentKind;
|
|
body: string;
|
|
/** Origin role that produced this content, for debugging. */
|
|
role?: string;
|
|
}
|
|
|
|
export interface ContentRegistry {
|
|
/** Real files on disk, keyed by path. */
|
|
pathIndex: Record<string, string>;
|
|
/** Inline content defined inside each doc, keyed by id. */
|
|
docContent: Record<string, Record<string, DocContent>>;
|
|
}
|
|
|
|
export const EMPTY_REGISTRY: ContentRegistry = {
|
|
pathIndex: {},
|
|
docContent: {},
|
|
};
|
|
|
|
/**
|
|
* Normalize a file path into a stable registry key: forward slashes and a
|
|
* single leading `/`. Both the CLI filesystem walk and the browser folder
|
|
* scan funnel through this so identical files always share a key.
|
|
*/
|
|
export function normalizePathKey(path: string): string {
|
|
const normalized = path.split("\\").join("/");
|
|
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Id / hash derivation (single source of truth)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Browser-safe content hash — stable across CLI and frontend. */
|
|
export function contentHash(body: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body.charCodeAt(i);
|
|
hash = ((hash << 5) - hash + ch) | 0;
|
|
}
|
|
return Math.abs(hash).toString(16).slice(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Derive a stable content id.
|
|
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
|
|
*/
|
|
export function deriveContentId(
|
|
kind: ContentKind,
|
|
body: string,
|
|
id?: string,
|
|
): string {
|
|
if (id) return id;
|
|
return `${kind}_${contentHash(body)}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-doc scanning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DocScanResult {
|
|
/** Content with blocks processed (stripped or replaced with directives). */
|
|
stripped: string;
|
|
/** Inline content defined in this doc, keyed by id. */
|
|
content: Record<string, DocContent>;
|
|
}
|
|
|
|
/**
|
|
* Process a single markdown doc:
|
|
* - dispatches attributed fenced code blocks by `role`
|
|
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
|
|
* - collects inline content (spark-table, declare, file) into the doc's
|
|
* content store
|
|
*
|
|
* Does NOT touch the path index — the caller assembles the registry.
|
|
*/
|
|
export function scanDoc(content: string, docPath: string): DocScanResult {
|
|
const contentStore: Record<string, DocContent> = {};
|
|
|
|
const stripped = content.replace(
|
|
FENCED_BLOCK_RE,
|
|
(
|
|
match: string,
|
|
lang: string,
|
|
infoString: string,
|
|
body: string,
|
|
): string => {
|
|
const attrs = parseBlockAttrs(infoString);
|
|
attrs.lang = attrs.lang || lang;
|
|
|
|
switch (attrs.role) {
|
|
case "declare": {
|
|
const id = deriveContentId("declare", body, attrs.id);
|
|
contentStore[id] = { id, kind: "declare", body, role: attrs.role };
|
|
return "";
|
|
}
|
|
|
|
case "file": {
|
|
const id = deriveContentId("text", body, attrs.id);
|
|
contentStore[id] = { id, kind: "text", body, role: attrs.role };
|
|
return "";
|
|
}
|
|
|
|
case "spark-table": {
|
|
let blockBody = body;
|
|
if (isMarkdownTableLang(attrs.lang)) {
|
|
blockBody = markdownTableBodyToCsv(body, docPath);
|
|
}
|
|
const id = deriveContentId("csv", blockBody, attrs.id);
|
|
contentStore[id] = {
|
|
id,
|
|
kind: "csv",
|
|
body: blockBody,
|
|
role: attrs.role,
|
|
};
|
|
|
|
const extraStr = Object.entries(attrs.extra)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(" ");
|
|
return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
|
|
}
|
|
|
|
case "tag":
|
|
// Kept intact so the code-block-yaml-tag render extension sees it.
|
|
return match;
|
|
|
|
case undefined:
|
|
// No role declared — plain visible code block.
|
|
return match;
|
|
|
|
default:
|
|
console.warn(
|
|
`[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`,
|
|
);
|
|
return "";
|
|
}
|
|
},
|
|
);
|
|
|
|
return { stripped, content: contentStore };
|
|
}
|
|
|
|
/**
|
|
* Build a `ContentRegistry` from a raw `{ path: content }` index.
|
|
*
|
|
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
|
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
|
* both modes produce identical `pathIndex`/`docContent` — including the
|
|
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
|
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
|
* both modes produce identical `pathIndex`/`docContent`.
|
|
*
|
|
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
|
* `scanDoc`, other extensions are stored raw.
|
|
*/
|
|
export function buildRegistryFromIndex(
|
|
index: Record<string, string>,
|
|
): ContentRegistry {
|
|
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
|
|
|
for (const [rawPath, content] of Object.entries(index)) {
|
|
const path = normalizePathKey(rawPath);
|
|
if (path.endsWith(".md")) {
|
|
const result = scanDoc(content, path);
|
|
registry.pathIndex[path] = result.stripped;
|
|
registry.docContent[path] = result.content;
|
|
} else {
|
|
registry.pathIndex[path] = content;
|
|
}
|
|
}
|
|
|
|
return registry;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spark table coercion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Whether a table header cell is a dice formula (a "spark table" first
|
|
* column). Used to validate `role=spark-table` blocks (CSV or markdown
|
|
* pipe-table bodies).
|
|
*/
|
|
export function isSparkTableHeader(header: string): boolean {
|
|
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
|
}
|
|
|
|
/** Split a markdown table row into cells. */
|
|
function splitTableRow(row: string): string[] {
|
|
return row
|
|
.replace(/^\|/, "")
|
|
.replace(/\|$/, "")
|
|
.split("|")
|
|
.map((c) => c.trim());
|
|
}
|
|
|
|
/** Escape a cell value for CSV output. */
|
|
function escapeCsvCell(cell: string): string {
|
|
if (
|
|
cell.includes(",") ||
|
|
cell.includes("\n") ||
|
|
cell.includes('"') ||
|
|
cell.includes("#")
|
|
) {
|
|
return `"${cell.replace(/"/g, '""')}"`;
|
|
}
|
|
return cell;
|
|
}
|
|
|
|
/** Convert a markdown table (header + separator + rows) to a CSV string. */
|
|
function markdownTableToCsv(
|
|
headerRow: string,
|
|
separatorRow: string,
|
|
bodyRows: string[],
|
|
): string | null {
|
|
const headers = splitTableRow(headerRow);
|
|
if (headers.length === 0) return null;
|
|
|
|
const sepCells = splitTableRow(separatorRow);
|
|
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
|
if (sepCells.length !== headers.length) return null;
|
|
|
|
const csvHeader = headers.map(escapeCsvCell).join(",");
|
|
const csvRows = bodyRows.map((row) => {
|
|
const cells = splitTableRow(row);
|
|
while (cells.length < headers.length) cells.push("");
|
|
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
|
});
|
|
|
|
return [csvHeader, ...csvRows].join("\n");
|
|
}
|
|
|
|
/**
|
|
* Languages whose fenced-block bodies are markdown pipe tables. Used with
|
|
* `role=spark-table` to convert the table to CSV at scan time — explicitly
|
|
* authorized by the role, never by content shape.
|
|
*/
|
|
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
|
|
|
function isMarkdownTableLang(lang: string): boolean {
|
|
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
|
|
* blocks. Validates the dice-formula first column (warning only — the role
|
|
* already declared intent) and falls back to storing the body as-is when it
|
|
* is not a recognizable pipe table.
|
|
*/
|
|
function markdownTableBodyToCsv(body: string, docPath: string): string {
|
|
const lines = body
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.filter((l) => l.trim().startsWith("|"));
|
|
if (lines.length < 2) {
|
|
console.warn(
|
|
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
|
|
);
|
|
return body;
|
|
}
|
|
|
|
const [headerRow, separatorRow, ...rows] = lines;
|
|
const headers = splitTableRow(headerRow);
|
|
if (!isSparkTableHeader(headers[0] || "")) {
|
|
console.warn(
|
|
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
|
|
);
|
|
}
|
|
|
|
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A resolved content reference: the body plus how it was addressed.
|
|
*/
|
|
export interface ResolvedContent {
|
|
body: string;
|
|
/** Content id for inline content, path-index key for real files. */
|
|
path: string;
|
|
/** True when resolved from the doc's inline content store. */
|
|
inline: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve a content reference from within a doc.
|
|
*
|
|
* - `ref` is an absolute path → path index.
|
|
* - `ref` is a relative path → resolved against the doc directory; checks
|
|
* the doc's inline content store first, then the path index.
|
|
*
|
|
* Refs are always ids or paths — inline content must be defined in a fenced
|
|
* block and referenced by its `./{id}`.
|
|
*
|
|
* Returns `null` when nothing matches.
|
|
*/
|
|
export function resolveContentEntry(
|
|
registry: ContentRegistry,
|
|
docPath: string,
|
|
ref: string,
|
|
): ResolvedContent | null {
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) return null;
|
|
|
|
if (trimmed.startsWith("/")) {
|
|
const body = registry.pathIndex[trimmed];
|
|
return body == null ? null : { body, path: trimmed, inline: false };
|
|
}
|
|
|
|
// Inline content in the same doc (e.g. ./{id}).
|
|
const docStore = registry.docContent[docPath];
|
|
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
|
|
const entry = docStore?.[id];
|
|
if (entry) return { body: entry.body, path: id, inline: true };
|
|
|
|
// Relative path resolved against the doc directory (`./` already stripped).
|
|
const resolved = posixJoin(posixDir(docPath), id);
|
|
const body = registry.pathIndex[resolved];
|
|
return body != null ? { body, path: resolved, inline: false } : null;
|
|
}
|
|
|
|
/** Resolve a content reference to its body only. */
|
|
export function resolveContent(
|
|
registry: ContentRegistry,
|
|
docPath: string,
|
|
ref: string,
|
|
): string | null {
|
|
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
|
|
* by searching every doc's content store for a matching id. Used by the
|
|
* frontend when a directive ref has already been resolved to a path.
|
|
*
|
|
* Returns `null` when no inline content matches.
|
|
*/
|
|
export function resolveInlineByPath(
|
|
registry: ContentRegistry,
|
|
resolvedPath: string,
|
|
): string | null {
|
|
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
|
|
if (!id) return null;
|
|
for (const store of Object.values(registry.docContent)) {
|
|
const entry = store[id];
|
|
if (entry) return entry.body;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derived completions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Derive the completions payload from the registry.
|
|
* Pure function — recompute on any file change.
|
|
*
|
|
* Dice + spark completions come from scanning each doc's stripped content
|
|
* for directives (resolving CSV refs through the registry), so both inline
|
|
* and real-file spark tables are covered. Declarations come from the doc
|
|
* content store.
|
|
*/
|
|
export function deriveCompletions(
|
|
registry: ContentRegistry,
|
|
): CompletionsPayload {
|
|
const links = deriveLinks(registry.pathIndex);
|
|
const dice: DiceCompletion[] = [];
|
|
const sparkTables: SparkTableCompletion[] = [];
|
|
const declarations: VarDeclaration[] = [];
|
|
const tagModifiers: TagModifier[] = [];
|
|
|
|
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
|
|
if (!docPath.endsWith(".md")) continue;
|
|
const found = scanDocDirectives(content, docPath, registry);
|
|
dice.push(...found.dice);
|
|
sparkTables.push(...found.sparkTables);
|
|
}
|
|
|
|
const blocks = deriveBlocks(registry);
|
|
declarations.push(...blocks.declarations);
|
|
tagModifiers.push(...blocks.tagModifiers);
|
|
|
|
return { dice, links, sparkTables, declarations, tagModifiers };
|
|
}
|
|
|
|
/**
|
|
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
|
|
* directives, resolving CSV refs through the registry.
|
|
*/
|
|
function scanDocDirectives(
|
|
content: string,
|
|
docPath: string,
|
|
registry: ContentRegistry,
|
|
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
|
|
const dice = scanDice(content, docPath);
|
|
const sparkTables: SparkTableCompletion[] = [];
|
|
|
|
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
|
const [, , ref, extraStr] = m;
|
|
const resolved = resolveContentEntry(registry, docPath, ref);
|
|
if (!resolved) continue;
|
|
|
|
const attrs = parseBlockAttrs(extraStr || "").extra;
|
|
const st = buildSparkTableCompletion(
|
|
resolved.body,
|
|
docPath,
|
|
resolved.path,
|
|
attrs["remix"] === "true",
|
|
);
|
|
if (st) sparkTables.push(st);
|
|
}
|
|
|
|
return { dice, sparkTables };
|
|
}
|
|
|
|
/** Derive variable declarations + tag modifiers from the registry. */
|
|
export function deriveBlocks(registry: ContentRegistry): {
|
|
declarations: VarDeclaration[];
|
|
tagModifiers: TagModifier[];
|
|
} {
|
|
const declarations: VarDeclaration[] = [];
|
|
const tagModifiers: TagModifier[] = [];
|
|
|
|
for (const [docPath, store] of Object.entries(registry.docContent)) {
|
|
for (const entry of Object.values(store)) {
|
|
if (entry.kind !== "declare") continue;
|
|
try {
|
|
const result = parseDeclareCsv(entry.body, docPath);
|
|
declarations.push(...result.variables);
|
|
tagModifiers.push(...result.tagModifiers);
|
|
} catch (e) {
|
|
console.warn(`[content-registry] ${docPath}: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { declarations, tagModifiers };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derivation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Remove fenced code blocks (backtick or tilde) from content, so text
|
|
* scanners (headings, dice directives) don't match example code.
|
|
*/
|
|
function stripFencedBlocks(content: string): string {
|
|
const out: string[] = [];
|
|
let fence: string | null = null;
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const fenceMatch = /^(`{3,}|~{3,})/.exec(line);
|
|
if (fence) {
|
|
if (line.startsWith(fence)) fence = null;
|
|
continue;
|
|
}
|
|
if (fenceMatch) {
|
|
fence = fenceMatch[1];
|
|
continue;
|
|
}
|
|
out.push(line);
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** Extract headings from all `.md` files as link completions. */
|
|
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
|
const items: LinkCompletion[] = [];
|
|
for (const [filePath, rawContent] of Object.entries(pathIndex)) {
|
|
if (!filePath.endsWith(".md")) continue;
|
|
|
|
// Headings inside fenced code blocks (e.g. markdown examples) are not
|
|
// real headings — exclude them from link completions.
|
|
const content = stripFencedBlocks(rawContent);
|
|
const basePath = filePath.replace(/\.md$/, "");
|
|
const fileName = fileNameFromPath(basePath);
|
|
const slugger = new Slugger();
|
|
|
|
items.push({ path: basePath, label: fileName, section: null });
|
|
|
|
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = headingRegex.exec(content)) !== null) {
|
|
const title = match[2].trim();
|
|
const id = slugger.slug(title.toLowerCase());
|
|
items.push({
|
|
path: basePath,
|
|
label: `${fileName} § ${title}`,
|
|
section: id,
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
|
|
|
|
function looksLikeDice(raw: string): boolean {
|
|
if (raw.length > 80) return false;
|
|
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
|
}
|
|
|
|
/** Scan a text body for `:md-dice[...]` directives. */
|
|
function scanDice(body: string, source: string): DiceCompletion[] {
|
|
const dice: DiceCompletion[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
|
|
const raw = m[1].trim();
|
|
if (!raw || !looksLikeDice(raw)) continue;
|
|
dice.push({ label: raw, notation: raw, source });
|
|
}
|
|
return dice;
|
|
}
|
|
|
|
/**
|
|
* Inspect a CSV body and return its data-column headers if it's a spark
|
|
* table (first column header is a dice formula), or null otherwise.
|
|
*/
|
|
export function inspectSparkTableCsv(csv: string): string[] | null {
|
|
const lines = csv.trim().split(/\r?\n/);
|
|
if (lines.length < 2) return null;
|
|
|
|
const headers = lines[0].split(",").map((h) => h.trim());
|
|
if (headers.length < 2) return null;
|
|
if (!isSparkTableHeader(headers[0])) return null;
|
|
|
|
return headers.slice(1);
|
|
}
|
|
|
|
/** Build a SparkTableCompletion from a CSV body. */
|
|
export function buildSparkTableCompletion(
|
|
csv: string,
|
|
docPath: string,
|
|
contentId: string,
|
|
remix: boolean,
|
|
): SparkTableCompletion | null {
|
|
const dataHeaders = inspectSparkTableCsv(csv);
|
|
if (!dataHeaders) return null;
|
|
|
|
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
|
|
const slugger = new Slugger();
|
|
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
|
|
|
const basePath = docPath.replace(/\.md$/, "");
|
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
const combinedSlug = `${fileName}-${slug}`;
|
|
|
|
return {
|
|
label: `${fileName} § ${slug}`,
|
|
notation,
|
|
slug: combinedSlug,
|
|
filePath: basePath,
|
|
docPath,
|
|
csvPath: contentId,
|
|
headers: dataHeaders,
|
|
remix,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path helpers (browser-safe posix)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function posixDir(path: string): string {
|
|
const idx = path.lastIndexOf("/");
|
|
return idx >= 0 ? path.slice(0, idx) : ".";
|
|
}
|
|
|
|
function posixJoin(dir: string, rel: string): string {
|
|
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
|
|
const base = dir.replace(/\/+$/, "");
|
|
const r = rel.replace(/^\/+/, "");
|
|
return `${base}/${r}`;
|
|
}
|
|
|
|
function fileNameFromPath(path: string): string {
|
|
const parts = path.split("/").filter(Boolean);
|
|
return parts[parts.length - 1] || path;
|
|
}
|