enzostvs HF Staff commited on
Commit
5131535
·
1 Parent(s): 6544cd3

add a way to create space in a follow up request

Browse files
app/api/ask/route.ts CHANGED
@@ -16,13 +16,15 @@ import {
16
  SEARCH_START,
17
  UPDATE_PAGE_START,
18
  UPDATE_PAGE_END,
 
19
  } from "@/lib/prompts";
20
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
21
  import { Page } from "@/types";
22
- import { uploadFiles } from "@huggingface/hub";
23
  import { isAuthenticated } from "@/lib/auth";
24
  import { getBestProvider } from "@/lib/best-provider";
25
  import { rewritePrompt } from "@/lib/rewrite-prompt";
 
26
 
27
  const ipAddresses = new Map();
28
 
@@ -31,7 +33,7 @@ export async function POST(request: NextRequest) {
31
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
32
 
33
  const body = await request.json();
34
- const { prompt, provider, model, redesignMarkdown, enhancedSettings } = body;
35
 
36
  if (!model || (!prompt && !redesignMarkdown)) {
37
  return NextResponse.json(
@@ -131,7 +133,7 @@ export async function POST(request: NextRequest) {
131
  },
132
  {
133
  role: "user",
134
- content: `${rewrittenPrompt}${redesignMarkdown ? `\n\nHere is my current design as a markdown:\n\n${redesignMarkdown}\n\nNow, please create a new design based on this markdown. Use the images in the markdown.` : ""}`
135
  },
136
  ],
137
  max_tokens: selectedProvider.max_tokens,
@@ -220,10 +222,12 @@ export async function PUT(request: NextRequest) {
220
  const authHeaders = await headers();
221
 
222
  const body = await request.json();
223
- const { prompt, previousPrompts, provider, selectedElementHtml, model, pages, files, repoId } =
224
  body;
225
 
226
- if (!prompt || pages.length === 0 || !repoId) {
 
 
227
  return NextResponse.json(
228
  { ok: false, error: "Missing required fields" },
229
  { status: 400 }
@@ -300,7 +304,7 @@ export async function PUT(request: NextRequest) {
300
  messages: [
301
  {
302
  role: "system",
303
- content: FOLLOW_UP_SYSTEM_PROMPT,
304
  },
305
  {
306
  role: "user",
@@ -514,6 +518,42 @@ export async function PUT(request: NextRequest) {
514
  files.push(file);
515
  });
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  const response = await uploadFiles({
518
  repo: {
519
  type: "space",
@@ -528,6 +568,7 @@ export async function PUT(request: NextRequest) {
528
  ok: true,
529
  updatedLines,
530
  pages: updatedPages,
 
531
  commit: {
532
  ...response.commit,
533
  title: prompt,
 
16
  SEARCH_START,
17
  UPDATE_PAGE_START,
18
  UPDATE_PAGE_END,
19
+ PROMPT_FOR_PROJECT_NAME,
20
  } from "@/lib/prompts";
21
  import MY_TOKEN_KEY from "@/lib/get-cookie-name";
22
  import { Page } from "@/types";
23
+ import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub";
24
  import { isAuthenticated } from "@/lib/auth";
25
  import { getBestProvider } from "@/lib/best-provider";
26
  import { rewritePrompt } from "@/lib/rewrite-prompt";
27
+ import { COLORS } from "@/lib/utils";
28
 
29
  const ipAddresses = new Map();
30
 
 
33
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
34
 
35
  const body = await request.json();
36
+ const { prompt, provider, model, redesignMarkdown, enhancedSettings, pages } = body;
37
 
38
  if (!model || (!prompt && !redesignMarkdown)) {
39
  return NextResponse.json(
 
133
  },
134
  {
135
  role: "user",
136
+ content: `${rewrittenPrompt}${redesignMarkdown ? `\n\nHere is my current design as a markdown:\n\n${redesignMarkdown}\n\nNow, please create a new design based on this markdown. Use the images in the markdown.` : ""} : ""}`
137
  },
138
  ],
139
  max_tokens: selectedProvider.max_tokens,
 
222
  const authHeaders = await headers();
223
 
224
  const body = await request.json();
225
+ const { prompt, previousPrompts, provider, selectedElementHtml, model, pages, files, repoId: repoIdFromBody, isNew, enhancedSettings } =
226
  body;
227
 
228
+ let repoId = repoIdFromBody;
229
+
230
+ if (!prompt || pages.length === 0) {
231
  return NextResponse.json(
232
  { ok: false, error: "Missing required fields" },
233
  { status: 400 }
 
304
  messages: [
305
  {
306
  role: "system",
307
+ content: FOLLOW_UP_SYSTEM_PROMPT + (isNew ? PROMPT_FOR_PROJECT_NAME : ""),
308
  },
309
  {
310
  role: "user",
 
518
  files.push(file);
519
  });
520
 
521
+ if (isNew) {
522
+ const projectName = chunk.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
523
+ const formattedTitle = projectName?.toLowerCase()
524
+ .replace(/[^a-z0-9]+/g, "-")
525
+ .split("-")
526
+ .filter(Boolean)
527
+ .join("-")
528
+ .slice(0, 96);
529
+ const repo: RepoDesignation = {
530
+ type: "space",
531
+ name: `${user.name}/${formattedTitle}`,
532
+ };
533
+ const { repoUrl} = await createRepo({
534
+ repo,
535
+ accessToken: user.token as string,
536
+ });
537
+ repoId = repoUrl.split("/").slice(-2).join("/");
538
+ const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)];
539
+ const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)];
540
+ const README = `---
541
+ title: ${projectName}
542
+ colorFrom: ${colorFrom}
543
+ colorTo: ${colorTo}
544
+ emoji: 🐳
545
+ sdk: static
546
+ pinned: false
547
+ tags:
548
+ - deepsite-v3
549
+ ---
550
+
551
+ # Welcome to your new DeepSite project!
552
+ This project was created with [DeepSite](https://deepsite.hf.co).
553
+ `;
554
+ files.push(new File([README], "README.md", { type: "text/markdown" }));
555
+ }
556
+
557
  const response = await uploadFiles({
558
  repo: {
559
  type: "space",
 
568
  ok: true,
569
  updatedLines,
570
  pages: updatedPages,
571
+ repoId,
572
  commit: {
573
  ...response.commit,
574
  title: prompt,
components/editor/ask-ai/index.tsx CHANGED
@@ -41,7 +41,7 @@ export const AskAi = ({
41
  onScrollToBottom?: () => void;
42
  }) => {
43
  const { user, projects } = useUser();
44
- const { currentPageData, isUploading, pages, isLoadingProject } = useEditor();
45
  const {
46
  isAiWorking,
47
  isThinking,
@@ -75,10 +75,6 @@ export const AskAi = ({
75
  const [think, setThink] = useState("");
76
  const [openThink, setOpenThink] = useState(false);
77
 
78
- const isSameHtml = useMemo(() => {
79
- return isTheSameHtml(currentPageData.html);
80
- }, [currentPageData.html]);
81
-
82
  const handleThink = (think: string) => {
83
  setThink(think);
84
  setIsThinking(true);
@@ -93,7 +89,7 @@ export const AskAi = ({
93
  if (!redesignMarkdown && !prompt.trim()) return;
94
 
95
  if (isFollowUp && !redesignMarkdown && !isSameHtml) {
96
- const result = await callAiFollowUp(prompt, enhancedSettings);
97
 
98
  if (result?.error) {
99
  handleError(result.error, result.message);
@@ -223,7 +219,7 @@ export const AskAi = ({
223
  />
224
  </div>
225
  <div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
226
- <div className="flex-1 flex items-center justify-start gap-1.5">
227
  <PromptBuilder
228
  enhancedSettings={enhancedSettings!}
229
  setEnhancedSettings={setEnhancedSettings}
 
41
  onScrollToBottom?: () => void;
42
  }) => {
43
  const { user, projects } = useUser();
44
+ const { isSameHtml, isUploading, pages, isLoadingProject } = useEditor();
45
  const {
46
  isAiWorking,
47
  isThinking,
 
75
  const [think, setThink] = useState("");
76
  const [openThink, setOpenThink] = useState(false);
77
 
 
 
 
 
78
  const handleThink = (think: string) => {
79
  setThink(think);
80
  setIsThinking(true);
 
89
  if (!redesignMarkdown && !prompt.trim()) return;
90
 
91
  if (isFollowUp && !redesignMarkdown && !isSameHtml) {
92
+ const result = await callAiFollowUp(prompt, enhancedSettings, isNew);
93
 
94
  if (result?.error) {
95
  handleError(result.error, result.message);
 
219
  />
220
  </div>
221
  <div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
222
+ <div className="flex-1 flex items-center justify-start gap-1.5 flex-wrap">
223
  <PromptBuilder
224
  enhancedSettings={enhancedSettings!}
225
  setEnhancedSettings={setEnhancedSettings}
components/editor/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
  import { useMemo, useRef, useState, useEffect } from "react";
3
- import { useCopyToClipboard } from "react-use";
4
  import { CopyIcon } from "lucide-react";
5
  import { toast } from "sonner";
6
  import classNames from "classnames";
@@ -10,6 +10,7 @@ import Editor from "@monaco-editor/react";
10
  import { useEditor } from "@/hooks/useEditor";
11
  import { Header } from "@/components/editor/header";
12
  import { useAi } from "@/hooks/useAi";
 
13
 
14
  import { ListPages } from "./pages";
15
  import { AskAi } from "./ask-ai";
@@ -47,7 +48,17 @@ export const AppEditor = ({
47
  const editor = useRef<HTMLDivElement>(null);
48
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
49
 
50
- // Show save popup when there are unsaved changes
 
 
 
 
 
 
 
 
 
 
51
  useEffect(() => {
52
  if (hasUnsavedChanges && !isAiWorking) {
53
  setShowSavePopup(true);
@@ -92,11 +103,9 @@ export const AppEditor = ({
92
  horizontal: "hidden",
93
  },
94
  wordWrap: "on",
95
- readOnly: !!isAiWorking || !!currentCommit || isNew,
96
  readOnlyMessage: {
97
- value: isNew
98
- ? "You can't edit the code, as this is a new project. Ask DeepSite first."
99
- : currentCommit
100
  ? "You can't edit the code, as this is an old version of the project."
101
  : "Wait for DeepSite to finish working...",
102
  isTrusted: true,
 
1
  "use client";
2
  import { useMemo, useRef, useState, useEffect } from "react";
3
+ import { useCopyToClipboard, useMount } from "react-use";
4
  import { CopyIcon } from "lucide-react";
5
  import { toast } from "sonner";
6
  import classNames from "classnames";
 
10
  import { useEditor } from "@/hooks/useEditor";
11
  import { Header } from "@/components/editor/header";
12
  import { useAi } from "@/hooks/useAi";
13
+ import { defaultHTML } from "@/lib/consts";
14
 
15
  import { ListPages } from "./pages";
16
  import { AskAi } from "./ask-ai";
 
48
  const editor = useRef<HTMLDivElement>(null);
49
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
50
 
51
+ useMount(() => {
52
+ if (isNew) {
53
+ setPages([
54
+ {
55
+ path: "index.html",
56
+ html: defaultHTML,
57
+ },
58
+ ]);
59
+ }
60
+ });
61
+
62
  useEffect(() => {
63
  if (hasUnsavedChanges && !isAiWorking) {
64
  setShowSavePopup(true);
 
103
  horizontal: "hidden",
104
  },
105
  wordWrap: "on",
106
+ readOnly: !!isAiWorking || !!currentCommit,
107
  readOnlyMessage: {
108
+ value: currentCommit
 
 
109
  ? "You can't edit the code, as this is an old version of the project."
110
  : "Wait for DeepSite to finish working...",
111
  isTrusted: true,
components/editor/preview/index.tsx CHANGED
@@ -33,6 +33,7 @@ export const Preview = forwardRef<LivePreviewRef, { isNew: boolean }>(
33
  pages,
34
  setPages,
35
  setCurrentPage,
 
36
  } = useEditor();
37
  const {
38
  isEditableModeEnabled,
@@ -239,7 +240,7 @@ export const Preview = forwardRef<LivePreviewRef, { isNew: boolean }>(
239
  </span>
240
  </div>
241
  )}
242
- {isNew && !isLoadingProject && !globalAiLoading ? (
243
  <iframe
244
  className={classNames(
245
  "w-full select-none transition-all duration-200 bg-black h-full",
 
33
  pages,
34
  setPages,
35
  setCurrentPage,
36
+ isSameHtml,
37
  } = useEditor();
38
  const {
39
  isEditableModeEnabled,
 
240
  </span>
241
  </div>
242
  )}
243
+ {isNew && !isLoadingProject && !globalAiLoading && isSameHtml ? (
244
  <iframe
245
  className={classNames(
246
  "w-full select-none transition-all duration-200 bg-black h-full",
hooks/useAi.ts CHANGED
@@ -14,7 +14,7 @@ import { LivePreviewRef } from "@/components/editor/live-preview";
14
  export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefObject<LivePreviewRef | null>) => {
15
  const client = useQueryClient();
16
  const audio = useRef<HTMLAudioElement | null>(null);
17
- const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages } = useEditor();
18
  const [controller, setController] = useState<AbortController | null>(null);
19
  const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
20
  const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
@@ -256,7 +256,7 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
256
  }
257
  };
258
 
259
- const callAiFollowUp = async (prompt: string, enhancedSettings?: EnhancedSettings) => {
260
  if (isAiWorking) return;
261
  if (!prompt.trim()) return;
262
 
@@ -266,7 +266,7 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
266
  const abortController = new AbortController();
267
  setController(abortController);
268
 
269
- try {
270
  const request = await fetch("/api/ask", {
271
  method: "PUT",
272
  body: JSON.stringify({
@@ -277,7 +277,9 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
277
  pages,
278
  selectedElementHtml: selectedElement?.outerHTML,
279
  files: selectedFiles,
280
- repoId: project?.space_id
 
 
281
  }),
282
  headers: {
283
  "Content-Type": "application/json",
@@ -307,20 +309,25 @@ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefO
307
  }
308
 
309
  toast.success("AI responded successfully");
310
- setIsAiWorking(false);
311
  const iframe = document.getElementById(
312
  "preview-iframe"
313
  ) as HTMLIFrameElement;
314
 
315
- setPages(res.pages);
316
- setLastSavedPages([...res.pages]); // Mark AI changes as saved
317
- setCommits([res.commit, ...commits]);
318
- setPrompts(
319
- [...prompts, prompt]
320
- )
321
- setSelectedElement(null);
322
- setSelectedFiles([]);
323
- setIsEditableModeEnabled(false);
 
 
 
 
 
 
324
  if (audio.current) audio.current.play();
325
  if (iframe) {
326
  setTimeout(() => {
 
14
  export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefObject<LivePreviewRef | null>) => {
15
  const client = useQueryClient();
16
  const audio = useRef<HTMLAudioElement | null>(null);
17
+ const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages, isSameHtml } = useEditor();
18
  const [controller, setController] = useState<AbortController | null>(null);
19
  const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
20
  const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
 
256
  }
257
  };
258
 
259
+ const callAiFollowUp = async (prompt: string, enhancedSettings?: EnhancedSettings, isNew?: boolean) => {
260
  if (isAiWorking) return;
261
  if (!prompt.trim()) return;
262
 
 
266
  const abortController = new AbortController();
267
  setController(abortController);
268
 
269
+ try {
270
  const request = await fetch("/api/ask", {
271
  method: "PUT",
272
  body: JSON.stringify({
 
277
  pages,
278
  selectedElementHtml: selectedElement?.outerHTML,
279
  files: selectedFiles,
280
+ repoId: project?.space_id,
281
+ isNew,
282
+ enhancedSettings,
283
  }),
284
  headers: {
285
  "Content-Type": "application/json",
 
309
  }
310
 
311
  toast.success("AI responded successfully");
 
312
  const iframe = document.getElementById(
313
  "preview-iframe"
314
  ) as HTMLIFrameElement;
315
 
316
+ if (isNew && res.repoId) {
317
+ router.push(`/projects/${res.repoId}`);
318
+ setIsAiWorking(false);
319
+ } else {
320
+ setPages(res.pages);
321
+ setLastSavedPages([...res.pages]); // Mark AI changes as saved
322
+ setCommits([res.commit, ...commits]);
323
+ setPrompts(
324
+ [...prompts, prompt]
325
+ )
326
+ setSelectedElement(null);
327
+ setSelectedFiles([]);
328
+ setIsEditableModeEnabled(false);
329
+ }
330
+
331
  if (audio.current) audio.current.play();
332
  if (iframe) {
333
  setTimeout(() => {
hooks/useEditor.ts CHANGED
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
7
  import { defaultHTML } from "@/lib/consts";
8
  import { Commit, HtmlHistory, Page, Project } from "@/types";
9
  import { api } from "@/lib/api";
 
10
 
11
  export const useEditor = (namespace?: string, repoId?: string) => {
12
  const client = useQueryClient();
@@ -60,7 +61,7 @@ export const useEditor = (namespace?: string, repoId?: string) => {
60
  const { data: pages = [] } = useQuery<Page[]>({
61
  queryKey: ["editor.pages"],
62
  queryFn: async (): Promise<Page[]> => {
63
- return pagesStorage ?? [
64
  {
65
  path: "index.html",
66
  html: defaultHTML,
@@ -71,7 +72,7 @@ export const useEditor = (namespace?: string, repoId?: string) => {
71
  refetchOnReconnect: false,
72
  refetchOnMount: false,
73
  retry: false,
74
- initialData: pagesStorage ?? [
75
  {
76
  path: "index.html",
77
  html: defaultHTML,
@@ -319,6 +320,10 @@ export const useEditor = (namespace?: string, repoId?: string) => {
319
  }
320
  }, [namespace, repoId])
321
 
 
 
 
 
322
  return {
323
  isLoadingProject,
324
  project,
@@ -341,6 +346,7 @@ export const useEditor = (namespace?: string, repoId?: string) => {
341
  currentCommit,
342
  setCurrentCommit,
343
  setProject,
 
344
  isUploading: uploadFilesMutation.isPending,
345
  globalEditorLoading: uploadFilesMutation.isPending || isLoadingProject,
346
  // Unsaved changes functionality
 
7
  import { defaultHTML } from "@/lib/consts";
8
  import { Commit, HtmlHistory, Page, Project } from "@/types";
9
  import { api } from "@/lib/api";
10
+ import { isTheSameHtml } from "@/lib/compare-html-diff";
11
 
12
  export const useEditor = (namespace?: string, repoId?: string) => {
13
  const client = useQueryClient();
 
61
  const { data: pages = [] } = useQuery<Page[]>({
62
  queryKey: ["editor.pages"],
63
  queryFn: async (): Promise<Page[]> => {
64
+ return [
65
  {
66
  path: "index.html",
67
  html: defaultHTML,
 
72
  refetchOnReconnect: false,
73
  refetchOnMount: false,
74
  retry: false,
75
+ initialData: [
76
  {
77
  path: "index.html",
78
  html: defaultHTML,
 
320
  }
321
  }, [namespace, repoId])
322
 
323
+ const isSameHtml = useMemo(() => {
324
+ return isTheSameHtml(currentPageData.html);
325
+ }, [pages]);
326
+
327
  return {
328
  isLoadingProject,
329
  project,
 
346
  currentCommit,
347
  setCurrentCommit,
348
  setProject,
349
+ isSameHtml,
350
  isUploading: uploadFilesMutation.isPending,
351
  globalEditorLoading: uploadFilesMutation.isPending || isLoadingProject,
352
  // Unsaved changes functionality
lib/prompts.ts CHANGED
@@ -74,21 +74,25 @@ If it's a new page, you MUST applied the following NEW_PAGE_START and UPDATE_PAG
74
  ${PROMPT_FOR_IMAGE_GENERATION}
75
  Do NOT explain the changes or what you did, just return the expected results.
76
  Update Format Rules:
77
- 1. Start with ${UPDATE_PAGE_START}
78
- 2. Provide the name of the page you are modifying.
79
- 3. Close the start tag with the ${UPDATE_PAGE_END}.
80
- 4. Start with ${SEARCH_START}
81
- 5. Provide the exact lines from the current code that need to be replaced.
82
- 6. Use ${DIVIDER} to separate the search block from the replacement.
83
- 7. Provide the new lines that should replace the original lines.
84
- 8. End with ${REPLACE_END}
85
- 9. You can use multiple SEARCH/REPLACE blocks if changes are needed in different parts of the file.
86
- 10. To insert code, use an empty SEARCH block (only ${SEARCH_START} and ${DIVIDER} on their lines) if inserting at the very beginning, otherwise provide the line *before* the insertion point in the SEARCH block and include that line plus the new lines in the REPLACE block.
87
- 11. To delete code, provide the lines to delete in the SEARCH block and leave the REPLACE block empty (only ${DIVIDER} and ${REPLACE_END} on their lines).
88
- 12. IMPORTANT: The SEARCH block must *exactly* match the current code, including indentation and whitespace.
 
 
 
89
  Example Modifying Code:
90
  \`\`\`
91
  Some explanation...
 
92
  ${UPDATE_PAGE_START}index.html${UPDATE_PAGE_END}
93
  ${SEARCH_START}
94
  <h1>Old Title</h1>
 
74
  ${PROMPT_FOR_IMAGE_GENERATION}
75
  Do NOT explain the changes or what you did, just return the expected results.
76
  Update Format Rules:
77
+ 1. Start with ${PROJECT_NAME_START}.
78
+ 2. Add the name of the project, right after the start tag.
79
+ 3. Close the start tag with the ${PROJECT_NAME_END}.
80
+ 4. Start with ${UPDATE_PAGE_START}
81
+ 5. Provide the name of the page you are modifying.
82
+ 6. Close the start tag with the ${UPDATE_PAGE_END}.
83
+ 7. Start with ${SEARCH_START}
84
+ 8. Provide the exact lines from the current code that need to be replaced.
85
+ 9. Use ${DIVIDER} to separate the search block from the replacement.
86
+ 10. Provide the new lines that should replace the original lines.
87
+ 11. End with ${REPLACE_END}
88
+ 12. You can use multiple SEARCH/REPLACE blocks if changes are needed in different parts of the file.
89
+ 13. To insert code, use an empty SEARCH block (only ${SEARCH_START} and ${DIVIDER} on their lines) if inserting at the very beginning, otherwise provide the line *before* the insertion point in the SEARCH block and include that line plus the new lines in the REPLACE block.
90
+ 14. To delete code, provide the lines to delete in the SEARCH block and leave the REPLACE block empty (only ${DIVIDER} and ${REPLACE_END} on their lines).
91
+ 15. IMPORTANT: The SEARCH block must *exactly* match the current code, including indentation and whitespace.
92
  Example Modifying Code:
93
  \`\`\`
94
  Some explanation...
95
+ ${PROJECT_NAME_START}Project Name${PROJECT_NAME_END}
96
  ${UPDATE_PAGE_START}index.html${UPDATE_PAGE_END}
97
  ${SEARCH_START}
98
  <h1>Old Title</h1>