diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs
index d854af7a9f..0c4d9ddf27 100644
--- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs
+++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs
@@ -10,8 +10,40 @@ import CompileManager from '../Compile/CompileManager.mjs'
import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs'
import { userCanInstallPython } from '../Compile/PythonVenvGate.mjs'
import { PublishedPresentation } from '../../models/PublishedPresentation.mjs'
+import ProjectGetter from '../Project/ProjectGetter.mjs'
import Errors from '../Errors/Errors.js'
+function _escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+// Wrapper page so a published PDF opens inline (browser PDF viewer) at the
+// snapshot root /p/:token, the same way an HTML deck does. The raw file stays
+// reachable at /p/:token/output.pdf for direct download.
+function _pdfIndexHtml(title) {
+ const safeTitle = _escapeHtml(title || 'Document')
+ return `
+
+
+
+
+${safeTitle}
+
+
+
+
+
+
+`
+}
+
const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder
// Output files we never want in a published deck: compile logs and LaTeX aux.
@@ -60,9 +92,11 @@ async function publish(projectId, userId) {
allowPythonInstall: await userCanInstallPython(userId, projectId),
})
- if (!outputFiles?.some(f => f.path === 'output.html')) {
+ const hasHtml = outputFiles?.some(f => f.path === 'output.html')
+ const hasPdf = outputFiles?.some(f => f.path === 'output.pdf')
+ if (!hasHtml && !hasPdf) {
throw new Errors.InvalidError(
- `project did not produce an HTML presentation (compile status: ${status})`
+ `project did not produce an HTML or PDF output (compile status: ${status})`
)
}
if (!buildId) {
@@ -101,12 +135,24 @@ async function publish(projectId, userId) {
)
}
- // Serve the deck at the snapshot root: /p/:token → output.html.
+ // Serve at the snapshot root: /p/:token → index.html. An HTML deck is its
+ // own index; a PDF gets a tiny wrapper page that embeds output.pdf inline.
try {
- await fs.promises.copyFile(
- Path.join(destDir, 'output.html'),
- Path.join(destDir, 'index.html')
- )
+ if (hasHtml) {
+ await fs.promises.copyFile(
+ Path.join(destDir, 'output.html'),
+ Path.join(destDir, 'index.html')
+ )
+ } else {
+ const project = await ProjectGetter.promises.getProject(projectId, {
+ name: 1,
+ })
+ await fs.promises.writeFile(
+ Path.join(destDir, 'index.html'),
+ _pdfIndexHtml(project?.name),
+ 'utf8'
+ )
+ }
} catch (err) {
logger.warn({ err, projectId }, 'could not create index.html for deck')
}
diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx
index e8c69c7270..7dedf73779 100644
--- a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx
+++ b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx
@@ -5,14 +5,20 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { postJSON } from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
+import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
// One-click shortcut: publish the compiled presentation as a private
// (logged-in-users-only) standalone page and open it in a new tab. For finer
// control (public links, unpublish) use the Share dialog.
+//
+// Only meaningful for HTML/RevealJS decks — a PDF compile has nothing to
+// "present", so the button hides itself when the current output isn't HTML.
export default function PresentationPreviewButton() {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const projectId = getMeta('ol-project_id')
+ const { pdfFile } = useCompileContext()
+ const isHtmlPresentation = pdfFile?.path === 'output.html'
const handleClick = useCallback(() => {
setLoading(true)
@@ -36,6 +42,10 @@ export default function PresentationPreviewButton() {
.finally(() => setLoading(false))
}, [projectId])
+ if (!isHtmlPresentation) {
+ return null
+ }
+
return (
{
diff --git a/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts
new file mode 100644
index 0000000000..d72da9bf06
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts
@@ -0,0 +1,69 @@
+import { EditorState, StateField } from '@codemirror/state'
+import {
+ ProjectionResult,
+ ProjectionStatus,
+} from '../../utils/tree-operations/projection'
+import {
+ FlatOutlineItem,
+ NestingLevel,
+} from '../../utils/tree-operations/outline'
+
+// Typst headings are '='-prefixed lines: '=' is a section, '==' a subsection,
+// and so on. Map heading depth to the shared outline nesting levels.
+const LEVELS: NestingLevel[] = [
+ NestingLevel.Section,
+ NestingLevel.SubSection,
+ NestingLevel.SubSubSection,
+ NestingLevel.Paragraph,
+ NestingLevel.SubParagraph,
+]
+
+// A heading is one or more leading '=' at column 0, a space, then the title.
+// '==' as an equality operator never starts a line at column 0 with a space
+// after it, so this stays clear of code.
+const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
+
+function computeOutline(
+ state: EditorState
+): ProjectionResult {
+ const items: FlatOutlineItem[] = []
+
+ for (let n = 1; n <= state.doc.lines; n++) {
+ const line = state.doc.line(n)
+ const match = HEADING_REGEX.exec(line.text)
+ if (!match) continue
+
+ const depth = match[1].length
+ const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
+ // Strip a trailing label, e.g. '= Introduction '.
+ const title = match[2].replace(/\s*<[\w-]+>\s*$/, '').trim()
+
+ items.push({
+ line: n,
+ toLine: n,
+ title,
+ from: line.from,
+ to: line.to,
+ level,
+ } as FlatOutlineItem)
+ }
+
+ return { items, status: ProjectionStatus.Complete }
+}
+
+// The Typst language uses a StreamLanguage, whose syntax tree has no structural
+// heading nodes to project from, so we scan the document text directly. Typst
+// files are small, so a full rescan on each edit is cheap.
+export const typstDocumentOutline = StateField.define<
+ ProjectionResult
+>({
+ create(state) {
+ return computeOutline(state)
+ },
+ update(value, transaction) {
+ if (transaction.docChanged) {
+ return computeOutline(transaction.state)
+ }
+ return value
+ },
+})
diff --git a/services/web/frontend/js/features/source-editor/languages/typst/index.ts b/services/web/frontend/js/features/source-editor/languages/typst/index.ts
index 3e97d5e873..45fded8cd7 100644
--- a/services/web/frontend/js/features/source-editor/languages/typst/index.ts
+++ b/services/web/frontend/js/features/source-editor/languages/typst/index.ts
@@ -5,6 +5,7 @@ import {
} from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
import { typstCompletions } from './complete'
+import { typstDocumentOutline } from './document-outline'
const keywords = new Set([
'let',
@@ -153,5 +154,6 @@ export const TypstLanguage = StreamLanguage.define(parser)
export const typst = () => {
return new LanguageSupport(TypstLanguage, [
TypstLanguage.data.of({ autocomplete: typstCompletions }),
+ typstDocumentOutline,
])
}
diff --git a/services/web/frontend/stylesheets/components/nav.scss b/services/web/frontend/stylesheets/components/nav.scss
index 78b25c7e11..4804de88ce 100644
--- a/services/web/frontend/stylesheets/components/nav.scss
+++ b/services/web/frontend/stylesheets/components/nav.scss
@@ -13,8 +13,8 @@
url('../../../public/img/ol-brand/overleaf-white.svg')
);
- // Title, when used instead of a logo
- --navbar-title-font-size: var(--font-size-05);
+ // Title, when used instead of a logo (the Verso instance name + version)
+ --navbar-title-font-size: var(--font-size-07);
--navbar-title-color: var(--neutral-20);
--navbar-title-color-hover: var(--neutral-40);
diff --git a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss
index 24ef18cb51..7000192ea1 100644
--- a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss
+++ b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss
@@ -245,6 +245,18 @@ body {
}
}
+ // Let the Verso wordmark bleed past the lower section's asymmetric
+ // padding so it spans the full width of the sidebar column.
+ .ds-nav-verso-logo {
+ margin-left: calc(-1 * var(--spacing-05));
+ margin-right: calc(-1 * var(--spacing-08));
+
+ img {
+ width: 100%;
+ height: auto;
+ }
+ }
+
.project-list-sidebar-survey-link {
color: var(--content-secondary) !important;
}