@paroicms/site-generator-plugin 0.8.1 → 0.9.0

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.
@@ -0,0 +1,49 @@
1
+ import { invokeMessageGuard } from "../generator/llm-queries/invoke-message-guard.js";
2
+ import { invokeNewSiteAnalysis } from "../generator/llm-queries/invoke-new-site-analysis.js";
3
+ import { invokeUpdateSiteSchema } from "../generator/llm-queries/invoke-update-site-schema.js";
4
+ import { generateSite } from "../generator/site-generator/site-generator.js";
5
+ import { newSession, verifySessionToken } from "./generator-session.js";
6
+ export async function executeCommand(commandCtx, input) {
7
+ if (input.command === "newSession") {
8
+ return {
9
+ success: true,
10
+ result: await newSession(commandCtx, input),
11
+ };
12
+ }
13
+ const { sessionId } = verifySessionToken(commandCtx, input.sessionToken);
14
+ const ctx = {
15
+ ...commandCtx,
16
+ sessionId,
17
+ };
18
+ if (input.command === "createSiteSchema") {
19
+ const errorResponse = await invokeMessageGuard(ctx, input);
20
+ if (errorResponse)
21
+ return errorResponse;
22
+ return {
23
+ success: true,
24
+ result: await invokeNewSiteAnalysis(ctx, input),
25
+ };
26
+ }
27
+ if (input.command === "updateSiteSchema") {
28
+ const errorResponse = await invokeMessageGuard(ctx, input, {
29
+ skipOutOfScopeCheck: true,
30
+ });
31
+ if (errorResponse)
32
+ return errorResponse;
33
+ return {
34
+ success: true,
35
+ result: await invokeUpdateSiteSchema(ctx, input),
36
+ };
37
+ }
38
+ if (input.command === "generateSite") {
39
+ return {
40
+ success: true,
41
+ result: await generateSite(ctx, input),
42
+ };
43
+ }
44
+ ctx.logger.error(`Invalid command: ${input.command}`);
45
+ return {
46
+ success: false,
47
+ userMessage: "Invalid command",
48
+ };
49
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Custom API error class for handling API-specific errors
3
+ */
4
+ export class ApiError extends Error {
5
+ statusCode;
6
+ constructor(message, statusCode = 400) {
7
+ super(message);
8
+ this.name = "ApiError";
9
+ this.statusCode = statusCode;
10
+ }
11
+ }
12
+ /**
13
+ * Create a new API error
14
+ * @param message Error message
15
+ * @param statusCode HTTP status code (default: 400)
16
+ * @returns ApiError instance
17
+ */
18
+ export function createApiError(message, statusCode = 400) {
19
+ return new ApiError(message, statusCode);
20
+ }
@@ -0,0 +1,45 @@
1
+ import { invokeMessageGuard } from "./llm-queries/invoke-message-guard.js";
2
+ import { invokeNewSiteAnalysis } from "./llm-queries/invoke-new-site-analysis.js";
3
+ import { invokeUpdateSiteSchema } from "./llm-queries/invoke-update-site-schema.js";
4
+ import { newSession, verifySessionToken } from "./session/generator-session.js";
5
+ import { generateSite } from "./site-generator/site-generator.js";
6
+ export async function executeCommand(ctx, input) {
7
+ if (input.command === "newSession") {
8
+ return {
9
+ success: true,
10
+ result: await newSession(ctx, input),
11
+ };
12
+ }
13
+ verifySessionToken(input.sessionToken);
14
+ if (input.command === "createSiteSchema") {
15
+ const errorResponse = await invokeMessageGuard(ctx, input);
16
+ if (errorResponse)
17
+ return errorResponse;
18
+ return {
19
+ success: true,
20
+ result: await invokeNewSiteAnalysis(ctx, input),
21
+ };
22
+ }
23
+ if (input.command === "updateSiteSchema") {
24
+ const errorResponse = await invokeMessageGuard(ctx, input, {
25
+ skipOutOfScopeCheck: true,
26
+ });
27
+ if (errorResponse)
28
+ return errorResponse;
29
+ return {
30
+ success: true,
31
+ result: await invokeUpdateSiteSchema(ctx, input),
32
+ };
33
+ }
34
+ if (input.command === "generateSite") {
35
+ return {
36
+ success: true,
37
+ result: await generateSite(ctx, input),
38
+ };
39
+ }
40
+ ctx.logger.error(`Invalid command: ${input.command}`);
41
+ return {
42
+ success: false,
43
+ userMessage: "Invalid command",
44
+ };
45
+ }
@@ -0,0 +1,51 @@
1
+ import { getRandomImagePath } from "../lib/images-lib.js";
2
+ export function augmentWithComputedValues(list, nodeType, language) {
3
+ if (!nodeType.fields)
4
+ return;
5
+ for (const item of list) {
6
+ for (const f of nodeType.fields) {
7
+ const languageKey = f.storedOn === "node" ? "." : language;
8
+ if (f.storedAs === "mediaHandle") {
9
+ const value = getMediaGeneratedFieldContent(f, language);
10
+ if (value) {
11
+ item[f.name] = value;
12
+ }
13
+ }
14
+ else {
15
+ const value = getDefaultValueForField(f.name);
16
+ if (value !== undefined) {
17
+ item[f.name] = { [languageKey]: value };
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ function getMediaGeneratedFieldContent(f, language) {
24
+ const languageKey = f.storedOn === "node" ? "." : language;
25
+ if (f.dataType === "media") {
26
+ return { [languageKey]: { file: getRandomImagePath() } };
27
+ }
28
+ if (f.dataType === "gallery") {
29
+ return {
30
+ [languageKey]: {
31
+ files: [
32
+ getRandomImagePath(),
33
+ getRandomImagePath(),
34
+ getRandomImagePath(),
35
+ getRandomImagePath(),
36
+ getRandomImagePath(),
37
+ getRandomImagePath(),
38
+ ],
39
+ },
40
+ };
41
+ }
42
+ }
43
+ function getDefaultValueForField(fieldName) {
44
+ if (fieldName === "phone" || fieldName === "phone2")
45
+ return "0123456789";
46
+ if (fieldName === "phones")
47
+ return JSON.stringify(["0123456789", "9876543210"]);
48
+ if (fieldName === "updateDateTime")
49
+ return new Date().toISOString();
50
+ return undefined;
51
+ }
@@ -0,0 +1,33 @@
1
+ import jwt from "jsonwebtoken";
2
+ import { createApiError } from "../errors.js";
3
+ const JWT_SECRET = "init"; // Hardcoded JWT secret as specified
4
+ /**
5
+ * Verifies a session token and returns the session ID if valid
6
+ * @param token The JWT token to verify
7
+ * @returns The session ID contained in the token
8
+ * @throws ApiError if the token is invalid or expired
9
+ */
10
+ export function verifySessionToken(token) {
11
+ try {
12
+ // Verify and decode the token
13
+ const payload = jwt.verify(token, JWT_SECRET);
14
+ if (!payload || !payload.sessionId) {
15
+ throw createApiError("Invalid session token", 401);
16
+ }
17
+ return payload.sessionId;
18
+ }
19
+ catch (error) {
20
+ if (error.name === "TokenExpiredError") {
21
+ throw createApiError("Session token expired", 401);
22
+ }
23
+ else if (error.name === "JsonWebTokenError") {
24
+ throw createApiError("Invalid session token", 401);
25
+ }
26
+ // Re-throw our custom error
27
+ if (error.name === "ApiError") {
28
+ throw error;
29
+ }
30
+ // Unexpected error
31
+ throw createApiError("Session verification failed", 500);
32
+ }
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ // Default limits if not specified in configuration
2
+ const DEFAULT_REQUEST_TOKENS = 8000;
3
+ const DEFAULT_SESSION_TOKENS = 32000;
4
+ const DEFAULT_DAILY_TOKENS = 100000;
5
+ /**
6
+ * Initialize token tracking for a session
7
+ */
8
+ export function initTokenTracking(pluginConf) {
9
+ const requestLimit = pluginConf.maxRequestTokens ?? DEFAULT_REQUEST_TOKENS;
10
+ const sessionLimit = pluginConf.maxSessionTokens ?? DEFAULT_SESSION_TOKENS;
11
+ return {
12
+ requestLimit,
13
+ sessionLimit,
14
+ sessionUsage: 0,
15
+ userLimits: new Map(),
16
+ };
17
+ }
18
+ /**
19
+ * Roughly estimate token count from text length
20
+ * This is a simple approximation - for production, use a proper tokenizer
21
+ */
22
+ export function estimateTokenCount(text) {
23
+ // Rough estimate: 1 token ≈ 4 characters for English text
24
+ return Math.ceil(text.length / 4);
25
+ }
26
+ /**
27
+ * Track token usage for the current user
28
+ */
29
+ export function trackUserTokens(ctx, tokenCount) {
30
+ const { userKey, tokenUsage, logger } = ctx;
31
+ const dailyLimit = ctx.pluginConf.maxDailyTokens ?? DEFAULT_DAILY_TOKENS;
32
+ // Update session usage
33
+ tokenUsage.sessionUsage += tokenCount;
34
+ // Get or create user usage entry
35
+ let userUsage = tokenUsage.userLimits.get(userKey);
36
+ if (!userUsage) {
37
+ userUsage = {
38
+ dailyUsage: 0,
39
+ lastReset: new Date(),
40
+ totalRequests: 0,
41
+ };
42
+ tokenUsage.userLimits.set(userKey, userUsage);
43
+ }
44
+ // Reset daily usage if it's a new day
45
+ const today = new Date().toDateString();
46
+ if (userUsage.lastReset.toDateString() !== today) {
47
+ logger.info(`Resetting daily token usage for user ${userKey}`);
48
+ userUsage.dailyUsage = 0;
49
+ userUsage.lastReset = new Date();
50
+ }
51
+ // Update daily usage
52
+ userUsage.dailyUsage += tokenCount;
53
+ userUsage.totalRequests++;
54
+ // Log usage
55
+ logger.debug(`Token usage - User: ${userKey}, Request: ${tokenCount}, ` +
56
+ `Session: ${tokenUsage.sessionUsage}/${tokenUsage.sessionLimit}, ` +
57
+ `Daily: ${userUsage.dailyUsage}/${dailyLimit}`);
58
+ }
59
+ /**
60
+ * Check if token limits have been exceeded
61
+ * @throws Error if any limit is exceeded
62
+ */
63
+ export function checkTokenLimits(ctx, estimatedTokens) {
64
+ const { tokenUsage, userKey, logger } = ctx;
65
+ const dailyLimit = ctx.pluginConf.maxDailyTokens ?? DEFAULT_DAILY_TOKENS;
66
+ // Check single request limit
67
+ if (estimatedTokens > tokenUsage.requestLimit) {
68
+ logger.warn(`User ${userKey} exceeded request token limit: ${estimatedTokens}/${tokenUsage.requestLimit}`);
69
+ throw new Error(`Prompt exceeds maximum token limit of ${tokenUsage.requestLimit}`);
70
+ }
71
+ // Check session limit
72
+ if (tokenUsage.sessionUsage + estimatedTokens > tokenUsage.sessionLimit) {
73
+ logger.warn(`User ${userKey} exceeded session token limit: ${tokenUsage.sessionUsage}/${tokenUsage.sessionLimit}`);
74
+ throw new Error("Session token limit exceeded. Please start a new session.");
75
+ }
76
+ // Check daily limit
77
+ const userUsage = tokenUsage.userLimits.get(userKey);
78
+ if (userUsage && userUsage.dailyUsage + estimatedTokens > dailyLimit) {
79
+ logger.warn(`User ${userKey} exceeded daily token limit: ${userUsage.dailyUsage}/${dailyLimit}`);
80
+ throw new Error("Daily token limit exceeded. Please try again tomorrow.");
81
+ }
82
+ }
83
+ /**
84
+ * Safe wrapper for LLM invocation with token tracking and limits
85
+ */
86
+ export async function invokeWithTokenLimit(ctx, model, prompt, processor) {
87
+ // For text prompts, estimate tokens
88
+ let promptTokens = 0;
89
+ if (typeof prompt === "string") {
90
+ promptTokens = estimateTokenCount(prompt);
91
+ }
92
+ else if (prompt.message && typeof prompt.message === "string") {
93
+ promptTokens = estimateTokenCount(prompt.message);
94
+ }
95
+ else {
96
+ // JSON objects are harder to estimate; use a conservative approach
97
+ promptTokens = estimateTokenCount(JSON.stringify(prompt)) * 1.5;
98
+ }
99
+ // Check limits before making the call
100
+ checkTokenLimits(ctx, promptTokens);
101
+ // Make the LLM call
102
+ const response = await model.invoke(prompt);
103
+ // Estimate response tokens and track total
104
+ let responseContent = "";
105
+ if (typeof response.content === "string") {
106
+ responseContent = response.content;
107
+ }
108
+ else if (response.content && response.content.length > 0) {
109
+ // Handle array response
110
+ responseContent = response.content.join("\n");
111
+ }
112
+ const responseTokens = estimateTokenCount(responseContent);
113
+ const totalTokens = promptTokens + responseTokens;
114
+ // Track the usage
115
+ trackUserTokens(ctx, totalTokens);
116
+ // Process and return the response
117
+ return processor(response);
118
+ }
@@ -0,0 +1,33 @@
1
+ import { ApiError } from "@paroicms/public-server-lib";
2
+ import { nanoid } from "nanoid";
3
+ const { sign, verify } = (await import("jsonwebtoken")).default;
4
+ const JWT_SECRET = "init"; // FIXME: Hardcoded JWT secret as specified
5
+ export async function newSession(ctx, command) {
6
+ const { service } = ctx;
7
+ const { recaptchaToken } = command;
8
+ const isValid = await service.validateRecaptchaResponse(recaptchaToken);
9
+ if (!isValid)
10
+ throw new ApiError("Invalid reCAPTCHA token", 400);
11
+ const sessionId = nanoid();
12
+ const token = sign({ sessionId }, JWT_SECRET, { expiresIn: "24h" });
13
+ return { token };
14
+ }
15
+ /**
16
+ * Verifies a session token and returns the session ID if valid
17
+ */
18
+ export function verifySessionToken(token) {
19
+ let payload;
20
+ try {
21
+ payload = verify(token, JWT_SECRET);
22
+ }
23
+ catch (error) {
24
+ if (error.name === "TokenExpiredError")
25
+ throw new ApiError("Session token expired", 401);
26
+ if (error.name === "JsonWebTokenError")
27
+ throw new ApiError("Invalid session token", 401);
28
+ throw error;
29
+ }
30
+ if (!payload || !payload.sessionId)
31
+ throw new ApiError("Invalid session token", 401);
32
+ return payload.sessionId;
33
+ }
@@ -0,0 +1,17 @@
1
+ import { ApiError } from "@paroicms/public-server-lib";
2
+ import { nanoid } from "nanoid";
3
+ const { sign, verify } = (await import("jsonwebtoken")).default;
4
+ export async function newSession(ctx, command) {
5
+ const { service } = ctx;
6
+ const { recaptchaToken } = command;
7
+ const isValid = await service.validateRecaptchaResponse(recaptchaToken);
8
+ if (!isValid) {
9
+ throw new ApiError("Invalid reCAPTCHA token", {
10
+ status: 400,
11
+ });
12
+ }
13
+ const sessionId = nanoid();
14
+ const token = sign({ sessionId }, "init", // Hardcoded JWT secret as specified
15
+ { expiresIn: "1h" });
16
+ return { token };
17
+ }
@@ -0,0 +1,262 @@
1
+ export function getThemeCssContent() {
2
+ return `/* Reset */
3
+ * {
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ a,
8
+ a:visited {
9
+ color: inherit;
10
+ text-decoration: none;
11
+ }
12
+
13
+ /* Elements */
14
+
15
+ body {
16
+ background-color: #aaa;
17
+ margin: 0;
18
+ padding: 0;
19
+ }
20
+
21
+ img {
22
+ display: block;
23
+ max-width: 100%;
24
+ }
25
+
26
+ picture {
27
+ display: block;
28
+ }
29
+
30
+ /* Tile */
31
+
32
+ a > article {
33
+ background-color: #fff;
34
+ border-radius: 6px;
35
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
36
+ display: flex;
37
+ max-width: 340px;
38
+ overflow: hidden;
39
+ }
40
+ a > article div:first-child {
41
+ flex: 0 0 120px;
42
+ max-width: 120px;
43
+ }
44
+ a > article img {
45
+ height: 100%;
46
+ object-fit: cover;
47
+ width: 100%;
48
+ }
49
+ a > article div:last-child {
50
+ display: flex;
51
+ flex: 1;
52
+ flex-direction: column;
53
+ padding: 15px;
54
+ }
55
+ a > article h3 {
56
+ margin: 0 0 8px;
57
+ font-size: 16px;
58
+ font-weight: bold;
59
+ color: #333;
60
+ }
61
+ a > article p {
62
+ color: #666;
63
+ font-size: 14px;
64
+ line-height: 1.4;
65
+ margin: 0;
66
+ }
67
+
68
+ a:hover > article {
69
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
70
+ }
71
+
72
+ /* Classes */
73
+
74
+ ._bg2 {
75
+ background-color: #444;
76
+ color: #eee;
77
+ }
78
+
79
+ .Container {
80
+ max-width: 1200px;
81
+ margin: 0 auto;
82
+ padding: 0 20px;
83
+ }
84
+
85
+ .Page {
86
+ background-color: #fff;
87
+ color: #333;
88
+ padding: 5px 40px;
89
+ }
90
+
91
+ .TextWidth {
92
+ max-width: 600px;
93
+ margin: 0 auto;
94
+ }
95
+
96
+ .List {
97
+ display: flex;
98
+ gap: 20px;
99
+ flex-wrap: wrap;
100
+ }
101
+
102
+ .Header {
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: space-between;
106
+ padding-bottom: 15px;
107
+ padding-top: 15px;
108
+ color: #fff;
109
+ }
110
+
111
+ @media (max-width: 768px) {
112
+ .Header {
113
+ flex-wrap: wrap;
114
+ padding: 15px 0 5px;
115
+ }
116
+ }
117
+
118
+ .Header-logo {
119
+ display: flex;
120
+ align-items: center;
121
+ }
122
+
123
+ .Header-logo img {
124
+ margin-right: 10px;
125
+ border-radius: 50%;
126
+ }
127
+
128
+ .Header-title {
129
+ font-size: 20px;
130
+ font-weight: bold;
131
+ }
132
+
133
+ .Header-search {
134
+ padding: 0 10px;
135
+ }
136
+
137
+ .Text::after {
138
+ clear: both;
139
+ content: "";
140
+ display: block;
141
+ }
142
+
143
+ .Text .Img {
144
+ height: auto;
145
+ }
146
+
147
+ .Text .Img.left {
148
+ float: left;
149
+ margin: 5px 20px 10px 0;
150
+ }
151
+
152
+ .Text .Img.right {
153
+ float: right;
154
+ margin: 5px 0 10px 20px;
155
+ }
156
+
157
+ .Text .Img.left, .Text .Img.right {
158
+ max-width: 50%;
159
+ }
160
+
161
+ .Text .Img.center {
162
+ display: block;
163
+ margin: 20px auto;
164
+ }
165
+
166
+ .Text a,
167
+ .Text a:visited,
168
+ .TextLink,
169
+ .TextLink:visited {
170
+ color: #007bff;
171
+ cursor: pointer;
172
+ text-decoration: underline;
173
+ }
174
+
175
+ .Hero img {
176
+ height: auto;
177
+ width: 100%;
178
+ }
179
+
180
+ .InfiniteLoading-actionArea {
181
+ align-items: center;
182
+ display: flex;
183
+ justify-content: center;
184
+ }
185
+
186
+ .Button,
187
+ .SearchOpenerBtn,
188
+ .InfiniteLoading-btn {
189
+ background-color: #3498db;
190
+ border: 1px solid #3498db;
191
+ border-radius: 5px;
192
+ color: #fff;
193
+ cursor: pointer;
194
+ display: inline-block;
195
+ font-size: 16px;
196
+ margin: 10px 0;
197
+ padding: 10px 20px;
198
+ text-align: center;
199
+ text-decoration: none;
200
+ }
201
+ .Button:hover,
202
+ .SearchOpenerBtn:hover,
203
+ .InfiniteLoading-btn:hover {
204
+ background-color: #2980b9;
205
+ border-color: #2980b9;
206
+ }
207
+
208
+ .SearchOpenerBtn {
209
+ display: flex;
210
+ }
211
+
212
+ .Navbar {
213
+ align-items: center;
214
+ display: flex;
215
+ flex-grow: 1;
216
+ justify-content: center;
217
+ margin: 0 15px;
218
+ }
219
+
220
+ @media (max-width: 768px) {
221
+ .Navbar {
222
+ flex-basis: 100%;
223
+ order: 3;
224
+ margin: 10px 0 5px;
225
+ justify-content: flex-start;
226
+ overflow-x: auto;
227
+ padding-bottom: 5px;
228
+ }
229
+ }
230
+
231
+ .NavButton {
232
+ color: rgba(255, 255, 255, 0.85);
233
+ padding: 8px;
234
+ margin: 0 4px;
235
+ border-radius: 4px;
236
+ font-size: 17px;
237
+ font-weight: bold;
238
+ position: relative;
239
+ transition: all 0.2s ease;
240
+ }
241
+
242
+ .NavButton:hover {
243
+ color: #fff;
244
+ background-color: rgba(255, 255, 255, 0.1);
245
+ }
246
+
247
+ .NavButton.active {
248
+ color: #fff;
249
+ }
250
+
251
+ .NavButton.active::after {
252
+ content: "";
253
+ position: absolute;
254
+ bottom: 0;
255
+ left: 16px;
256
+ right: 16px;
257
+ height: 3px;
258
+ background-color: #3498db;
259
+ border-radius: 1.5px;
260
+ }
261
+ `;
262
+ }
@@ -0,0 +1,14 @@
1
+ export function createCommandContext(service, pluginConf, rawContext) {
2
+ const packConf = service.connector.getSitePackConf(pluginConf.packName);
3
+ const { sitesDir, packName } = packConf;
4
+ if (!sitesDir) {
5
+ throw new Error(`Site-generator plugin can generate sites only for pack with "sitesDir", but pack "${packName}" doesn't have it`);
6
+ }
7
+ return {
8
+ ...rawContext,
9
+ sitesDir,
10
+ packName,
11
+ service,
12
+ logger: service.logger,
13
+ };
14
+ }
@@ -6,4 +6,6 @@ A document always has the following base attributes: a localized _title_, a _pub
6
6
 
7
7
  A document can contain lists of **parts**. A _part_ is a sub-section of a document, or of another _part_. A part always has a _publish date_ and a _draft_ flag. It may contain a sequence of fields and/or a sequence of child parts. A part is always an item of a list.
8
8
 
9
- Any routing document which is parent of regular documents can be used as a **taxonomy**. Then, the terms are the regular child documents. Then a taxonomy can be used in any document or part, by declaring a **labeling field**.
9
+ Any routing document which is parent of regular documents can be used as a **taxonomy**. Then, the terms are the regular child documents. Then a taxonomy can be used in any document or part, by declaring a **labeling field**.
10
+
11
+ Documents and parts are **nodes** in a tree. Children are part of the definition of a node type. When 2 node types appear identical, but their children types are not the same, then they are 2 different node types and they should have 2 different names.
@@ -30,16 +30,17 @@ For this second step, follow these instructions for creating the bullet list:
30
30
 
31
31
  1. Carefully read and analyze the website description.
32
32
  2. Identify the main documents and parts of the website.
33
- - Notice: Children are part of the definition of a node type. When 2 node types appear identical, but their children types are not the same, then they are 2 different node types and they should have 2 different names.
34
33
  3. Determine the hierarchical relationships between documents and parts.
35
34
  4. Create a tree structure using a limited Markdown format to represent the website's tree structure.
36
35
 
36
+ Whenever you have a choice, keep it simple. If the user is not directive, if he gives a general and vague instruction, for example with a short prompt like “create a blog”, then limit the site sections to two main entries (in the example of a blog, a list of posts and a list of pages). Plus the usual contact and search pages.
37
+
37
38
  Guidelines for creating the hierarchical bullet list:
38
39
 
39
40
  - Write in English.
40
41
  - But, if the website description is not in English: do not translate book or post titles.
41
42
  - Use a Markdown syntax. Indent with 2 spaces. Use indentation to show parent-child relationships between node types.
42
- - Always include a homepage with key `home` at the top level.
43
+ - Always start with the homepage, with key `home` at the top level.
43
44
  - When you define an identifier for a node type name or a list name, use camel case and follow the identifier syntax of JavaScript.
44
45
  - Use `contactPage` as the default type name for contact page, and `searchPage` for the search page.
45
46
  - Bullet point format for a _routing document_: