Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ components.d.ts
.cursor

playground/docus/.data
docs/.data
docs/.data
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@
"@ai-sdk/gateway": "^3.0.55",
"@ai-sdk/vue": "^3.0.101",
"@iconify-json/lucide": "^1.2.94",
"@nuxtjs/mdc": "^0.20.1",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.101",
"comark": "https://pkg.pr.new/comarkdown/comark@8d51457",
"defu": "^6.1.4",
"destr": "^2.0.5",
"js-yaml": "^4.1.1",
"minimatch": "^10.2.4",
"nuxt-component-meta": "^0.17.2",
"remark-mdc": "^3.10.0",
"shiki": "^3.23.0",
"unstorage": "1.17.4",
"zod": "^4.3.6",
Expand Down
5,210 changes: 2,697 additions & 2,513 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

18 changes: 1 addition & 17 deletions src/app/src/components/content/editor/ContentEditor.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, type PropType } from 'vue'
import { decompressTree } from '@nuxt/content/runtime'
import type { MarkdownRoot } from '@nuxt/content'
import { DraftStatus, type DatabasePageItem, type DraftItem, type DatabaseItem } from '../../../types'
import { useStudio } from '../../../composables/useStudio'
import { useStudioState } from '../../../composables/useStudioState'
Expand Down Expand Up @@ -43,21 +41,7 @@ const document = computed<DatabasePageItem>({
return props.draftItem.original as DatabasePageItem
}

const dbItem = props.draftItem.modified as DatabasePageItem

let result: DatabasePageItem
if (dbItem.body?.type === 'minimark') {
result = {
...props.draftItem.modified as DatabasePageItem,
// @ts-expect-error todo fix MarkdownRoot/MDCRoot conversion in MDC module
body: decompressTree(dbItem.body) as MarkdownRoot,
}
}
else {
result = dbItem
}

return result
return props.draftItem.modified as DatabasePageItem
},
set(value) {
if (props.readOnly) {
Expand Down
48 changes: 16 additions & 32 deletions src/app/src/components/content/editor/ContentEditorTipTap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
import { Emoji } from '@tiptap/extension-emoji'
import type { PropType } from 'vue'
import type { JSONContent } from '@tiptap/vue-3'
import type { MDCRoot, Toc } from '@nuxtjs/mdc'
import { generateToc } from '@nuxtjs/mdc/dist/runtime/parser/toc'
import type { ComarkTree } from 'comark/ast'
import type { DraftItem, DatabasePageItem } from '../../../types'
import type { MarkdownRoot } from '@nuxt/content'
import { ref, watch, computed } from 'vue'
import { useStudio } from '../../../composables/useStudio'
import { useStudioState } from '../../../composables/useStudioState'
import { mdcToTiptap } from '../../../utils/tiptap/mdcToTiptap'
import { tiptapToMDC } from '../../../utils/tiptap/tiptapToMdc'
import { comarkToTiptap } from '../../../utils/tiptap/comarkToTiptap'
import { tiptapToComark } from '../../../utils/tiptap/tiptapToComark'
import { removeLastEmptyParagraph } from '../../../utils/tiptap/editor'
import { Element } from '../../../utils/tiptap/extensions/element'
import { Image } from '../../../utils/tiptap/extensions/image'
Expand All @@ -22,7 +20,6 @@ import { Frontmatter } from '../../../utils/tiptap/extensions/frontmatter'
import { CodeBlock } from '../../../utils/tiptap/extensions/code-block'
import { InlineElement } from '../../../utils/tiptap/extensions/inline-element'
import { SpanStyle } from '../../../utils/tiptap/extensions/span-style'
import { compressTree } from '@nuxt/content/runtime'
import TiptapSpanStylePopover from '../../tiptap/TiptapSpanStylePopover.vue'
import { Binding } from '../../../utils/tiptap/extensions/binding'
import { Callout } from '../../../utils/tiptap/extensions/callout'
Expand Down Expand Up @@ -70,12 +67,10 @@ const {

const tiptapJSON = ref<JSONContent>()

const cleanDataKeys = host.document.utils.cleanDataKeys

// Debug
const debug = computed(() => preferences.value.debug)
const currentTiptap = ref<JSONContent>()
const currentMDC = ref<{ body: MDCRoot, data: Record<string, unknown> }>()
const currentComark = ref<ComarkTree>()
const currentContent = ref<string>()

let isConverting = false
Expand All @@ -91,31 +86,23 @@ watch(tiptapJSON, async (json) => {

const cleanedTiptap = removeLastEmptyParagraph(json!)

const { body, data } = await tiptapToMDC(cleanedTiptap, {
// TipTap → ComarkTree (internal representation)
const comarkTree = await tiptapToComark(cleanedTiptap, {
highlightTheme: host.meta.getHighlightTheme(),
})

const compressedBody: MarkdownRoot = compressTree(body)
const toc: Toc = generateToc(body, { searchDepth: 2, depth: 2 } as Toc)

const updatedDocument: DatabasePageItem = {
...document.value!,
...data,
body: {
...compressedBody,
toc,
} as MarkdownRoot,
...comarkTree.frontmatter,
body: comarkTree,
}

document.value = updatedDocument

// Debug: Capture current state
if (debug.value) {
currentTiptap.value = cleanedTiptap
currentMDC.value = {
body,
data: cleanDataKeys(updatedDocument),
}
currentComark.value = comarkTree
currentContent.value = await host.document.generate.contentFromDocument(updatedDocument) as string
}

Expand All @@ -124,19 +111,16 @@ watch(tiptapJSON, async (json) => {

// Trigger on document changes
watch(() => `${document.value?.id}-${props.draftItem.version}-${props.draftItem.status}`, async () => {
const frontmatterJson = cleanDataKeys(document.value!)
const newTiptapJSON = mdcToTiptap(document.value?.body as unknown as MDCRoot, frontmatterJson, { hasNuxtUI: hasNuxtUI.value })
const comarkTree = document.value!.body
if (!comarkTree) return
const newTiptapJSON = comarkToTiptap(comarkTree, { hasNuxtUI: hasNuxtUI.value })

if (!tiptapJSON.value || JSON.stringify(newTiptapJSON) !== JSON.stringify(removeLastEmptyParagraph(tiptapJSON.value))) {
tiptapJSON.value = newTiptapJSON

if (debug.value && !currentMDC.value) {
const generateContentFromDocument = host.document.generate.contentFromDocument
const generatedContent = await generateContentFromDocument(document.value!) || ''
currentMDC.value = {
body: document.value!.body as unknown as MDCRoot,
data: frontmatterJson,
}
if (debug.value && !currentComark.value) {
const generatedContent = await host.document.generate.contentFromDocument(document.value!) || ''
currentComark.value = comarkTree
currentContent.value = generatedContent
currentTiptap.value = JSON.parse(JSON.stringify(tiptapJSON.value))
}
Expand All @@ -149,7 +133,7 @@ watch(() => `${document.value?.id}-${props.draftItem.version}-${props.draftItem.
<ContentEditorTipTapDebug
v-if="preferences.debug"
:current-tiptap="currentTiptap"
:current-mdc="currentMDC"
:current-comark="currentComark"
:current-content="currentContent"
/>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { computed, type PropType } from 'vue'
import type { JSONContent } from '@tiptap/vue-3'
import type { MDCRoot } from '@nuxtjs/mdc'
import type { ComarkTree } from 'comark/ast'

const props = defineProps({
currentTiptap: {
type: Object as PropType<JSONContent | undefined>,
default: undefined,
},
currentMdc: {
type: Object as PropType<{ body: MDCRoot, data: Record<string, unknown> } | undefined>,
currentComark: {
type: Object as PropType<ComarkTree | undefined>,
default: undefined,
},
currentContent: {
Expand All @@ -19,7 +19,7 @@ const props = defineProps({
})

const formattedCurrentTiptap = computed(() => props.currentTiptap ? JSON.stringify(props.currentTiptap, null, 2) : 'No data')
const formattedCurrentMDC = computed(() => props.currentMdc ? JSON.stringify(props.currentMdc, null, 2) : 'No data')
const formattedCurrentComark = computed(() => props.currentComark ? JSON.stringify(props.currentComark, null, 2) : 'No data')
</script>

<template>
Expand Down Expand Up @@ -60,22 +60,22 @@ const formattedCurrentMDC = computed(() => props.currentMdc ? JSON.stringify(pro
>{{ formattedCurrentTiptap || 'No data' }}</pre>
</UCard>

<!-- Current MDC -->
<!-- Current ComarkTree -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-highlighted">
MDC JSON
Comark AST
</h3>
</div>
<CopyButton :content="formattedCurrentMDC" />
<CopyButton :content="formattedCurrentComark" />
</div>
</template>

<pre
class="text-xs text-muted overflow-auto max-h-[250px] p-3 bg-default rounded-md border border-default"
>{{ formattedCurrentMDC || 'No data' }}</pre>
>{{ formattedCurrentComark || 'No data' }}</pre>
</UCard>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/app/src/types/database.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { CollectionItemBase, PageCollectionItemBase, DataCollectionItemBase } from '@nuxt/content'
import type { ComarkTree } from 'comark/ast'
import type { BaseItem } from './item'

export interface DatabaseItem extends CollectionItemBase, BaseItem {
[key: string]: unknown
}

export interface DatabasePageItem extends PageCollectionItemBase, BaseItem {
export interface DatabasePageItem extends Omit<PageCollectionItemBase, 'body' | 'excerpt'>, BaseItem {
path: string
body: ComarkTree
[key: string]: unknown
}

Expand Down
64 changes: 29 additions & 35 deletions src/app/src/utils/ai/completion.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { EditorState } from '@tiptap/pm/state'
import type { JSONContent } from '@tiptap/vue-3'
import type { AIHintOptions } from '../../types/ai'
import { tiptapSliceToMDC } from '../tiptap/tiptapToMdc'
import { mdcToTiptap } from '../tiptap/mdcToTiptap'
import { stringifyMarkdown } from '@nuxtjs/mdc/runtime'
import { parseMarkdown } from '@nuxtjs/mdc/runtime/parser/index'
import type { MDCElement, MDCNode, MDCRoot } from '@nuxtjs/mdc'
import { tiptapSliceToComark } from '../tiptap/tiptapToComark'
import { comarkToTiptap } from '../tiptap/comarkToTiptap'
import { parse } from 'comark'
import { renderMarkdown } from 'comark/string'
import type { ComarkTree, ComarkElement, ComarkNode } from 'comark/ast'

function isWhitespace(char: string): boolean {
return /\s/.test(char)
Expand Down Expand Up @@ -179,29 +179,23 @@ export function generateHintOptions(state: EditorState, cursorPos: number): AIHi
}

/**
* Clean MDC AST by removing all props (noise for AI context)
* Clean ComarkTree by removing all attrs from elements (noise for AI context)
* For AI completion, only content and structure matter, not implementation details
*/
function cleanMDC<T extends MDCRoot | MDCNode>(node: T): T {
if (node.type === 'root') {
// Recursively clean all children
node.children.forEach(child => cleanMDC(child))
return node
}

if (node.type === 'element') {
const element = node as MDCElement

// Remove all props from all elements
element.props = {}
function cleanComarkNode(node: ComarkNode): ComarkNode {
if (typeof node === 'string') return node // ComarkText
if (!Array.isArray(node)) return node
const [tag, , ...children] = node as ComarkElement
if (tag === null) return node // ComarkComment - keep as-is
// Element: strip all attrs, recursively clean children
return [tag, {}, ...(children as ComarkNode[]).map(cleanComarkNode)] as ComarkElement
}

// Recursively clean children
if (element.children) {
element.children.forEach(child => cleanMDC(child))
}
function cleanComark(tree: ComarkTree): ComarkTree {
return {
...tree,
nodes: tree.nodes.map(cleanComarkNode),
}

return node
}

/**
Expand All @@ -214,14 +208,14 @@ export async function tiptapSliceToMarkdown(
maxChars?: number,
trimDirection: 'start' | 'end' = 'end',
): Promise<string> {
// Convert TipTap slice to MDC AST
const { body, data } = await tiptapSliceToMDC(state, from, to)
// Convert TipTap slice to ComarkTree
const tree = await tiptapSliceToComark(state, from, to)

// Clean the AST by removing component props (reduces noise for AI)
const cleanedBody = cleanMDC(body)
// Clean the AST by removing component attrs (reduces noise for AI)
const cleanedTree = cleanComark(tree)

// Stringify MDC AST to markdown
const markdown = await stringifyMarkdown(cleanedBody, data)
// Stringify ComarkTree to markdown
const markdown = renderMarkdown(cleanedTree)

if (!markdown) {
return ''
Expand All @@ -241,14 +235,14 @@ export async function tiptapSliceToMarkdown(
* Convert markdown string to TipTap nodes (reverse of tiptapSliceToMarkdown)
*/
export async function markdownSliceToTiptap(markdown: string): Promise<JSONContent[]> {
// Parse markdown to MDC AST
const { body, data } = await parseMarkdown(markdown)
// Parse markdown to ComarkTree
const tree = await parse(markdown)

// Convert MDC AST to TipTap JSON
const tiptapDoc = mdcToTiptap(body, data)
// Convert ComarkTree directly to TipTap JSON
const tiptapDoc = comarkToTiptap(tree)

// Extract content nodes (skip frontmatter)
const contentNodes = (tiptapDoc.content || []).filter(node => node.type !== 'frontmatter')
const contentNodes = (tiptapDoc.content || []).filter((node: JSONContent) => node.type !== 'frontmatter')

// If the result is a single paragraph, extract its inline content
// This is common for AI completions that are just text with inline formatting
Expand Down
21 changes: 21 additions & 0 deletions src/app/src/utils/comark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ComarkNode, ComarkElement, ComarkComment } from 'comark/ast'

export function isElement(node: ComarkNode): node is ComarkElement {
return Array.isArray(node) && node[0] !== null
}

export function isComment(node: ComarkNode): node is ComarkComment {
return Array.isArray(node) && node[0] === null
}

export function getTag(node: ComarkElement): string {
return node[0] as string
}

export function getAttrs(node: ComarkElement): Record<string, unknown> {
return (node[1] as Record<string, unknown>) || {}
}

export function getChildren(node: ComarkElement): ComarkNode[] {
return node.slice(2) as ComarkNode[]
}
Loading
Loading