@cyber-dash-tech/revela 0.6.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ Enable it for the current session, assign a presentation task, and the agent can
23
23
  - uses workspace `DECKS.json` as machine-readable deck memory, slide spec, and prewrite readiness state
24
24
  - blocks premature writes to `decks/*.html` until the active deck is marked structurally ready
25
25
  - runs automatic layout QA whenever the agent writes `decks/*.html`
26
+ - opens a visual comment editor for existing decks so users can Ctrl/Cmd-click elements and send precise edit requests back to OpenCode
26
27
  - exports finished decks to PDF and editable PPTX
27
28
  - switches designs and domains locally with zero LLM cost
28
29
 
@@ -139,6 +140,7 @@ Disable presentation mode when done:
139
140
  /revela init initialize or refresh workspace DECKS.json
140
141
  /revela review [slug] review active deck readiness before writing HTML
141
142
  /revela remember <text> save an explicit user/workflow preference
143
+ /revela edit <target> open visual comment editor for a deck slug or decks/*.html
142
144
 
143
145
  /revela designs list installed designs
144
146
  /revela designs <name> activate a design
@@ -157,7 +159,7 @@ Disable presentation mode when done:
157
159
  /revela pptx <file> export an HTML deck to editable PPTX in the same directory
158
160
  ```
159
161
 
160
- Most `/revela` commands run locally with zero LLM cost. `/revela init`, `/revela review`, `/revela remember`, `/revela designs-new`, and `/revela designs-edit` start AI-assisted workflows because they need to read or update project files.
162
+ Most `/revela` commands run locally with zero LLM cost. `/revela init`, `/revela review`, `/revela remember`, `/revela designs-new`, and `/revela designs-edit` start AI-assisted workflows because they need to read or update project files. `/revela edit` opens a local visual editor and then sends user comments back into the current OpenCode session when the user submits them.
161
163
 
162
164
  ---
163
165
 
@@ -592,6 +594,23 @@ A custom domain is a folder containing `INDUSTRY.md`.
592
594
 
593
595
  ---
594
596
 
597
+ ## Visual Editing
598
+
599
+ Open the visual editor for an existing deck by slug or workspace-relative HTML path:
600
+
601
+ ```text
602
+ /revela edit my-deck
603
+ /revela edit decks/my-deck.html
604
+ ```
605
+
606
+ The editor opens in your browser. Use `Ctrl`/`Cmd` + click to reference deck elements, write a natural-language comment, then send it back to OpenCode. Revela sends a structured edit prompt that includes the deck file, slide context, selected element metadata, and your comment.
607
+
608
+ LLM tool equivalent: `revela-edit` with `{ "target": "decks/my-deck.html" }`. This lets the agent open the same editor when you say things like “I want to edit @decks/my-deck.html”.
609
+
610
+ `/revela edit` prepares minimal `DECKS.json` state for the existing HTML deck if needed, so the normal deck write gate can still protect `decks/*.html` while allowing targeted edits.
611
+
612
+ ---
613
+
595
614
  ## Export
596
615
 
597
616
  PDF export:
package/README.zh-CN.md CHANGED
@@ -23,6 +23,7 @@ Revela 是一个 [OpenCode](https://opencode.ai) 插件,可以把你当前使
23
23
  - 使用工作区 `DECKS.json` 保存机器可读的 deck 记忆、逐页规格和写入前 readiness 状态
24
24
  - 在 active deck 结构化 ready 前,阻止过早写入 `decks/*.html`
25
25
  - agent 每次写入 `decks/*.html` 时自动执行布局 QA
26
+ - 为已有 deck 打开可视化评论编辑器,用户可以 Ctrl/Cmd + 点击元素,并把精确修改意见发回 OpenCode
26
27
  - 支持导出成 PDF 和可编辑 PPTX
27
28
  - design 和 domain 的切换都在本地完成,不消耗 LLM token
28
29
 
@@ -138,6 +139,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
138
139
  /revela init 初始化或刷新工作区 DECKS.json
139
140
  /revela review [slug] 写 HTML 前检查 active deck readiness
140
141
  /revela remember <text> 保存明确的用户/工作流偏好
142
+ /revela edit <target> 为 deck slug 或 decks/*.html 打开可视化评论编辑器
141
143
 
142
144
  /revela designs 列出已安装 design
143
145
  /revela designs <name> 激活某个 design
@@ -156,7 +158,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
156
158
  /revela pptx <file> 将 HTML deck 导出为同目录可编辑 PPTX
157
159
  ```
158
160
 
159
- 大多数 `/revela` 命令都在本地执行,不消耗 LLM token。`/revela init`、`/revela review`、`/revela remember`、`/revela designs-new` 和 `/revela designs-edit` 会启动 AI 辅助流程,因为它们需要读取或更新项目状态。
161
+ 大多数 `/revela` 命令都在本地执行,不消耗 LLM token。`/revela init`、`/revela review`、`/revela remember`、`/revela designs-new` 和 `/revela designs-edit` 会启动 AI 辅助流程,因为它们需要读取或更新项目状态。`/revela edit` 会打开本地可视化编辑器,并在用户提交评论后把修改请求发回当前 OpenCode 会话。
160
162
 
161
163
  ---
162
164
 
@@ -557,6 +559,23 @@ Prompt 注入规则:
557
559
 
558
560
  ---
559
561
 
562
+ ## 可视化编辑
563
+
564
+ 可以通过 deck slug 或工作区相对 HTML 路径打开可视化编辑器:
565
+
566
+ ```text
567
+ /revela edit my-deck
568
+ /revela edit decks/my-deck.html
569
+ ```
570
+
571
+ 编辑器会在浏览器中打开。使用 `Ctrl`/`Cmd` + 点击 deck 元素来引用它们,写一段自然语言评论,然后发送回 OpenCode。Revela 会把 deck 文件、slide 上下文、选中元素 metadata 和你的评论整理成结构化 edit prompt。
572
+
573
+ 对应的 LLM tool:`revela-edit`,参数为 `{ "target": "decks/my-deck.html" }`。因此当你说“我要编辑 @decks/my-deck.html”时,agent 也可以主动打开同一个编辑器。
574
+
575
+ 如果已有 HTML deck 缺少 `DECKS.json` 状态,`/revela edit` 会自动准备最小 deck state,让正常的 `decks/*.html` 写入门禁仍然生效,同时允许后续精准修改。
576
+
577
+ ---
578
+
560
579
  ## 导出
561
580
 
562
581
  PDF 导出:
@@ -0,0 +1,31 @@
1
+ import { openEditableDeck } from "../edit/open"
2
+
3
+ export async function handleEdit(
4
+ input: string,
5
+ options: { client: any; sessionID: string; workspaceRoot: string },
6
+ send: (text: string) => Promise<void>,
7
+ ): Promise<void> {
8
+ const target = input.trim()
9
+ if (!target) {
10
+ await send("**Usage:** `/revela edit <deck-slug|decks/file.html>`\n\nExamples: `/revela edit investor-update`, `/revela edit decks/investor-update.html`")
11
+ return
12
+ }
13
+
14
+ try {
15
+ const result = openEditableDeck(target, {
16
+ client: options.client,
17
+ sessionID: options.sessionID,
18
+ workspaceRoot: options.workspaceRoot,
19
+ })
20
+
21
+ await send(
22
+ `Opened visual editor for deck \`${result.deck.slug}\`.\n` +
23
+ `File: \`${result.deck.file}\` (${result.source})\n` +
24
+ `${result.stateNote}\n` +
25
+ `URL: ${result.url}\n\n` +
26
+ `Use Ctrl/Cmd + click in the browser to reference elements, write a comment, then send comments. Revela mode has been enabled for the edit prompt.`
27
+ )
28
+ } catch (e: any) {
29
+ await send(`**Edit failed:** ${e.message || String(e)}`)
30
+ }
31
+ }
@@ -28,6 +28,7 @@ export async function handleHelp(
28
28
  `\`/revela disable\` — disable slide generation mode\n` +
29
29
  `\`/revela init\` — initialize or refresh workspace DECKS.json\n` +
30
30
  `\`/revela review [slug]\` — review active deck readiness before writing HTML\n` +
31
+ `\`/revela edit <target>\` — open visual comment editor for a deck slug or decks/*.html\n` +
31
32
  `\`/revela remember <text>\` — save an explicit preference to DECKS.json\n` +
32
33
  `\`/revela designs\` — list installed designs\n` +
33
34
  `\`/revela designs <name>\` — activate a design\n` +
package/lib/config.ts CHANGED
@@ -28,7 +28,7 @@ export const CONFIG_FILE = join(CONFIG_DIR, "config.json")
28
28
  export const ACTIVE_PROMPT_FILE = join(CONFIG_DIR, "_active-prompt.md")
29
29
 
30
30
  /** Default design name. */
31
- export const DEFAULT_DESIGN = "aurora"
31
+ export const DEFAULT_DESIGN = "summit"
32
32
 
33
33
  /** Default domain name. */
34
34
  export const DEFAULT_DOMAIN = "general"
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from "fs"
2
+ import { activeDesign } from "../design/designs"
3
+ import { activeDomain } from "../domain/domains"
4
+ import {
5
+ defaultRequiredInputs,
6
+ readOrCreateDecksState,
7
+ reviewDeckState,
8
+ upsertDeck,
9
+ upsertSlides,
10
+ writeDecksState,
11
+ type DeckStateReadinessResult,
12
+ type SlideSpec,
13
+ } from "../decks-state"
14
+ import type { EditableDeck } from "./resolve-deck"
15
+
16
+ export interface EditDeckStatePreflightResult {
17
+ changed: boolean
18
+ readiness: DeckStateReadinessResult
19
+ }
20
+
21
+ export function ensureEditableDeckState(workspaceRoot: string, deck: EditableDeck): EditDeckStatePreflightResult {
22
+ let state = readOrCreateDecksState(workspaceRoot)
23
+ const existing = state.decks[deck.slug]
24
+ const existingReady = existing?.writeReadiness?.status === "ready" && existing.writeReadiness.blockers.length === 0
25
+ let changed = !existing || existing.outputPath !== deck.file
26
+
27
+ state = upsertDeck(state, {
28
+ ...existing,
29
+ slug: deck.slug,
30
+ goal: existing?.goal || `Edit existing Revela deck ${deck.slug}.`,
31
+ audience: existing?.audience || "Existing deck viewers",
32
+ language: existing?.language || "en",
33
+ slideCount: existing?.slideCount || inferSlideCount(deck.absoluteFile),
34
+ outputPath: deck.file,
35
+ theme: {
36
+ design: existing?.theme?.design || safeActiveDesign(),
37
+ domain: existing?.theme?.domain || safeActiveDomain(),
38
+ },
39
+ requiredInputs: defaultRequiredInputs({
40
+ ...existing?.requiredInputs,
41
+ topicClarified: true,
42
+ audienceClarified: true,
43
+ slideCountDecided: true,
44
+ languageDecided: true,
45
+ visualStyleSelected: true,
46
+ sourceMaterialsIdentified: true,
47
+ researchNeedAssessed: true,
48
+ researchFindingsRead: true,
49
+ slidePlanConfirmed: true,
50
+ designLayoutsFetched: true,
51
+ }),
52
+ researchPlan: existing?.researchPlan || [],
53
+ })
54
+
55
+ const current = state.decks[deck.slug]
56
+ if (current.slides.length === 0) {
57
+ state = upsertSlides(state, deck.slug, inferSlides(deck.absoluteFile))
58
+ changed = true
59
+ }
60
+
61
+ const reviewed = reviewDeckState(state, deck.slug)
62
+ writeDecksState(workspaceRoot, reviewed.state)
63
+
64
+ return {
65
+ changed: changed || !existingReady,
66
+ readiness: reviewed.result,
67
+ }
68
+ }
69
+
70
+ function inferSlideCount(filePath: string): number {
71
+ return inferSlides(filePath).length
72
+ }
73
+
74
+ function inferSlides(filePath: string): SlideSpec[] {
75
+ const html = readFileSync(filePath, "utf-8")
76
+ const chunks = html.match(/<section\b[\s\S]*?<\/section>/gi) || [html]
77
+ return chunks.map((chunk, index) => {
78
+ const title = extractTitle(chunk) || `Slide ${index + 1}`
79
+ return {
80
+ index: index + 1,
81
+ title,
82
+ purpose: "Existing HTML slide prepared for targeted visual edits.",
83
+ layout: "existing-html",
84
+ qa: /slide-qa=["']true["']/i.test(chunk),
85
+ components: ["existing-html"],
86
+ content: {
87
+ headline: title,
88
+ body: [extractText(chunk) || "Existing HTML slide content."],
89
+ },
90
+ evidence: [],
91
+ visuals: [],
92
+ status: "ready",
93
+ notes: "Inferred automatically by /revela edit preflight.",
94
+ }
95
+ })
96
+ }
97
+
98
+ function extractTitle(html: string): string {
99
+ const match = /<(?:h1|h2|h3|title)\b[^>]*>([\s\S]*?)<\/(?:h1|h2|h3|title)>/i.exec(html)
100
+ return normalizeText(match?.[1] || "").slice(0, 160)
101
+ }
102
+
103
+ function extractText(html: string): string {
104
+ return normalizeText(html.replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ")).slice(0, 600)
105
+ }
106
+
107
+ function normalizeText(value: string): string {
108
+ return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim()
109
+ }
110
+
111
+ function safeActiveDesign(): string {
112
+ try {
113
+ return activeDesign()
114
+ } catch {
115
+ return "summit"
116
+ }
117
+ }
118
+
119
+ function safeActiveDomain(): string {
120
+ try {
121
+ return activeDomain()
122
+ } catch {
123
+ return "general"
124
+ }
125
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "fs"
2
+ import { ctx } from "../ctx"
3
+ import { ACTIVE_PROMPT_FILE } from "../config"
4
+ import { seedBuiltinDesigns } from "../design/designs"
5
+ import { seedBuiltinDomains } from "../domain/domains"
6
+ import { buildPrompt } from "../prompt-builder"
7
+ import { ensureEditableDeckState } from "./deck-state"
8
+ import { resolveEditableDeck, type EditableDeck } from "./resolve-deck"
9
+ import { startEditServer } from "./server"
10
+
11
+ export interface OpenEditableDeckResult {
12
+ deck: EditableDeck
13
+ url: string
14
+ source: string
15
+ stateNote: string
16
+ preflightChanged: boolean
17
+ }
18
+
19
+ export interface OpenEditableDeckOptions {
20
+ client: any
21
+ sessionID: string
22
+ workspaceRoot: string
23
+ openBrowser?: boolean
24
+ }
25
+
26
+ export function openUrl(url: string): void {
27
+ if (process.platform === "darwin") {
28
+ const proc = Bun.spawnSync(["open", url])
29
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
30
+ return
31
+ }
32
+
33
+ if (process.platform === "win32") {
34
+ const proc = Bun.spawnSync(["cmd", "/c", "start", "", url])
35
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
36
+ return
37
+ }
38
+
39
+ const proc = Bun.spawnSync(["xdg-open", url])
40
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
41
+ }
42
+
43
+ export function openEditableDeck(target: string, options: OpenEditableDeckOptions): OpenEditableDeckResult {
44
+ const deck = resolveEditableDeck(options.workspaceRoot, target)
45
+ const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
46
+ if (!preflight.readiness.ready) {
47
+ throw new Error(preflight.readiness.blocker || "Deck is not ready for HTML edits.")
48
+ }
49
+
50
+ ctx.enabled = true
51
+ if (!existsSync(ACTIVE_PROMPT_FILE)) {
52
+ seedBuiltinDesigns()
53
+ seedBuiltinDomains()
54
+ buildPrompt()
55
+ }
56
+
57
+ const editServer = startEditServer()
58
+ const token = editServer.createSession({
59
+ client: options.client,
60
+ sessionID: options.sessionID,
61
+ deck,
62
+ })
63
+ const url = `${editServer.baseUrl}/edit?token=${encodeURIComponent(token)}`
64
+ if (options.openBrowser !== false) openUrl(url)
65
+
66
+ const source = deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path"
67
+ const stateNote = preflight.changed ? "Deck state was prepared in DECKS.json before opening the editor." : "Deck state is ready in DECKS.json."
68
+
69
+ return {
70
+ deck,
71
+ url,
72
+ source,
73
+ stateNote,
74
+ preflightChanged: preflight.changed,
75
+ }
76
+ }
@@ -0,0 +1,81 @@
1
+ export interface EditSelectedElementPayload {
2
+ slideIndex?: number
3
+ slideTitle?: string
4
+ selector?: string
5
+ domPath?: string
6
+ tagName?: string
7
+ id?: string
8
+ classList?: string[]
9
+ text?: string
10
+ outerHTMLExcerpt?: string
11
+ nearbyText?: string
12
+ boundingBox?: Record<string, unknown>
13
+ viewport?: Record<string, unknown>
14
+ }
15
+
16
+ export interface EditCommentDraftPayload {
17
+ comment: string
18
+ elements: EditSelectedElementPayload[]
19
+ }
20
+
21
+ export interface EditCommentPayload extends EditSelectedElementPayload {
22
+ deck: string
23
+ file: string
24
+ comment: string
25
+ elements?: EditSelectedElementPayload[]
26
+ comments?: EditCommentDraftPayload[]
27
+ }
28
+
29
+ export function buildEditPrompt(payload: EditCommentPayload): string {
30
+ const elements = payload.elements?.length
31
+ ? payload.elements
32
+ : [{
33
+ slideIndex: payload.slideIndex,
34
+ slideTitle: payload.slideTitle,
35
+ selector: payload.selector,
36
+ domPath: payload.domPath,
37
+ tagName: payload.tagName,
38
+ id: payload.id,
39
+ classList: payload.classList ?? [],
40
+ text: payload.text,
41
+ outerHTMLExcerpt: payload.outerHTMLExcerpt,
42
+ nearbyText: payload.nearbyText,
43
+ boundingBox: payload.boundingBox,
44
+ viewport: payload.viewport,
45
+ }]
46
+ const comments = payload.comments?.length
47
+ ? payload.comments
48
+ : [{
49
+ comment: payload.comment,
50
+ elements,
51
+ }]
52
+
53
+ const compact = {
54
+ deck: payload.deck,
55
+ file: payload.file,
56
+ comments,
57
+ }
58
+
59
+ return `The user left a visual edit comment on a Revela slide deck.
60
+
61
+ Target deck: ${payload.deck}
62
+ Target file: ${payload.file}
63
+
64
+ Structured selection payload:
65
+
66
+ \`\`\`json
67
+ ${JSON.stringify(compact, null, 2)}
68
+ \`\`\`
69
+
70
+ Instructions:
71
+ - Make the smallest targeted change that satisfies the user's comment.
72
+ - If there are multiple comments, apply them as one coherent edit pass and avoid changes from one comment overwriting another.
73
+ - Each comment may reference one or more selected elements. Treat the elements in a single comment as a group.
74
+ - Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
75
+ - Do not rewrite unrelated slides or broad sections of the deck.
76
+ - Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
77
+ - Before patching or writing ${"`decks/*.html`"}, ensure ${"`DECKS.json`"} contains this deck and call ${"`revela-decks`"} with action ${"`review`"}. If ${"`DECKS.json`"} or the deck entry is missing, initialize/upsert the deck state with ${"`revela-decks`"} first. If readiness remains blocked, explain the blockers instead of forcing the edit.
78
+ - Apply the edit to ${payload.file} only after readiness allows deck HTML changes.
79
+ - After editing, run ${"`revela-qa`"} on ${payload.file} and fix any relevant regressions caused by the edit.
80
+ - If the comment is ambiguous, ask one concise clarification question instead of guessing.`
81
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync } from "fs"
2
+ import { basename, relative, resolve, sep } from "path"
3
+ import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
4
+
5
+ export interface EditableDeck {
6
+ slug: string
7
+ file: string
8
+ absoluteFile: string
9
+ source: "decks-state" | "fallback" | "file-path"
10
+ }
11
+
12
+ export function resolveEditableDeck(workspaceRoot: string, input: string): EditableDeck {
13
+ const requested = input.trim()
14
+ if (!requested) throw new Error("Usage: /revela edit <deck-slug|decks/file.html>")
15
+
16
+ const slug = normalizeSlug(requested)
17
+
18
+ if (hasDecksState(workspaceRoot)) {
19
+ const state = readDecksState(workspaceRoot)
20
+ const deck = state.decks[requested] ?? (slug ? state.decks[slug] : undefined)
21
+ if (deck) {
22
+ return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
23
+ }
24
+ }
25
+
26
+ if (looksLikePath(requested)) {
27
+ return resolvePathTarget(workspaceRoot, requested)
28
+ }
29
+
30
+ if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
31
+
32
+ return resolveDeckFile(workspaceRoot, slug, `decks/${slug}.html`, "fallback")
33
+ }
34
+
35
+ function resolvePathTarget(workspaceRoot: string, requested: string): EditableDeck {
36
+ if (isAbsoluteLike(requested)) {
37
+ throw new Error("/revela edit only accepts workspace-relative decks/*.html paths.")
38
+ }
39
+
40
+ const normalized = normalizePath(requested).replace(/^\.\//, "")
41
+ if (!isDeckHtmlPath(normalized)) {
42
+ throw new Error("/revela edit file paths must point to decks/*.html.")
43
+ }
44
+
45
+ const slug = normalizeSlug(basename(normalized, ".html"))
46
+ if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
47
+ return resolveDeckFile(workspaceRoot, slug, normalized, "file-path")
48
+ }
49
+
50
+ function resolveDeckFile(
51
+ workspaceRoot: string,
52
+ slug: string,
53
+ file: string,
54
+ source: EditableDeck["source"],
55
+ ): EditableDeck {
56
+ if (!isDeckHtmlPath(file)) {
57
+ throw new Error(`${DECKS_STATE_FILE} deck outputPath must be decks/*.html, got ${file || "missing"}.`)
58
+ }
59
+
60
+ const root = resolve(workspaceRoot)
61
+ const absoluteFile = resolve(root, file)
62
+ if (!isInside(root, absoluteFile)) {
63
+ throw new Error(`Resolved deck file is outside the workspace: ${file}`)
64
+ }
65
+ if (!existsSync(absoluteFile)) {
66
+ throw new Error(`Deck HTML not found: ${workspaceRelative(root, absoluteFile)}`)
67
+ }
68
+
69
+ return {
70
+ slug: normalizeSlug(slug),
71
+ file: workspaceRelative(root, absoluteFile),
72
+ absoluteFile,
73
+ source,
74
+ }
75
+ }
76
+
77
+ function isInside(root: string, target: string): boolean {
78
+ return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)
79
+ }
80
+
81
+ function workspaceRelative(root: string, target: string): string {
82
+ return relative(root, target).split(sep).join("/")
83
+ }
84
+
85
+ function looksLikePath(value: string): boolean {
86
+ return value.includes("/") || value.includes("\\") || value.endsWith(".html")
87
+ }
88
+
89
+ function isAbsoluteLike(value: string): boolean {
90
+ return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)
91
+ }
92
+
93
+ function normalizePath(value: string): string {
94
+ return value.replace(/\\/g, "/")
95
+ }
96
+
97
+ function normalizeSlug(value: string): string {
98
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
99
+ }