create-atsdc-stack 1.0.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.
- package/.claude/settings.local.json +9 -0
- package/CONTRIBUTING.md +342 -0
- package/INSTALLATION.md +359 -0
- package/LICENSE +201 -0
- package/README.md +405 -0
- package/app/.astro/settings.json +5 -0
- package/app/.astro/types.d.ts +1 -0
- package/app/.env.example +17 -0
- package/app/README.md +251 -0
- package/app/astro.config.mjs +83 -0
- package/app/drizzle.config.ts +16 -0
- package/app/package.json +52 -0
- package/app/public/manifest.webmanifest +36 -0
- package/app/src/components/Card.astro +36 -0
- package/app/src/db/initialize.ts +107 -0
- package/app/src/db/schema.ts +72 -0
- package/app/src/db/validations.ts +158 -0
- package/app/src/env.d.ts +1 -0
- package/app/src/layouts/Layout.astro +63 -0
- package/app/src/lib/config.ts +36 -0
- package/app/src/lib/content-converter.ts +141 -0
- package/app/src/lib/dom-utils.ts +230 -0
- package/app/src/lib/exa-search.ts +269 -0
- package/app/src/pages/api/chat.ts +91 -0
- package/app/src/pages/api/posts.ts +350 -0
- package/app/src/pages/index.astro +87 -0
- package/app/src/styles/components/button.scss +152 -0
- package/app/src/styles/components/card.scss +180 -0
- package/app/src/styles/components/form.scss +240 -0
- package/app/src/styles/global.scss +141 -0
- package/app/src/styles/pages/index.scss +80 -0
- package/app/src/styles/reset.scss +83 -0
- package/app/src/styles/variables/globals.scss +96 -0
- package/app/src/styles/variables/mixins.scss +238 -0
- package/app/tsconfig.json +45 -0
- package/bin/cli.js +1138 -0
- package/package.json +37 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod validation schemas for database models
|
|
5
|
+
* Provides runtime type safety and validation for user inputs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Slug validation helper
|
|
9
|
+
const slugSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1, 'Slug is required')
|
|
12
|
+
.max(255, 'Slug must be 255 characters or less')
|
|
13
|
+
.regex(
|
|
14
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
15
|
+
'Slug must be lowercase alphanumeric with hyphens only'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Post validation schemas
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Schema for creating a new post
|
|
23
|
+
export const createPostSchema = z.object({
|
|
24
|
+
title: z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1, 'Title is required')
|
|
27
|
+
.max(255, 'Title must be 255 characters or less')
|
|
28
|
+
.trim(),
|
|
29
|
+
|
|
30
|
+
slug: slugSchema,
|
|
31
|
+
|
|
32
|
+
content: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1, 'Content is required')
|
|
35
|
+
.max(50000, 'Content must be 50,000 characters or less'),
|
|
36
|
+
|
|
37
|
+
excerpt: z
|
|
38
|
+
.string()
|
|
39
|
+
.max(500, 'Excerpt must be 500 characters or less')
|
|
40
|
+
.optional()
|
|
41
|
+
.nullable(),
|
|
42
|
+
|
|
43
|
+
authorId: z.string().min(1, 'Author ID is required'),
|
|
44
|
+
|
|
45
|
+
authorName: z
|
|
46
|
+
.string()
|
|
47
|
+
.max(255, 'Author name must be 255 characters or less')
|
|
48
|
+
.optional()
|
|
49
|
+
.nullable(),
|
|
50
|
+
|
|
51
|
+
published: z.boolean().default(false),
|
|
52
|
+
|
|
53
|
+
featured: z.boolean().default(false),
|
|
54
|
+
|
|
55
|
+
metaTitle: z
|
|
56
|
+
.string()
|
|
57
|
+
.max(255, 'Meta title must be 255 characters or less')
|
|
58
|
+
.optional()
|
|
59
|
+
.nullable(),
|
|
60
|
+
|
|
61
|
+
metaDescription: z
|
|
62
|
+
.string()
|
|
63
|
+
.max(500, 'Meta description must be 500 characters or less')
|
|
64
|
+
.optional()
|
|
65
|
+
.nullable(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Schema for updating an existing post
|
|
69
|
+
export const updatePostSchema = createPostSchema.partial().extend({
|
|
70
|
+
id: z.string().length(21, 'Invalid post ID'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Schema for publishing/unpublishing a post
|
|
74
|
+
export const publishPostSchema = z.object({
|
|
75
|
+
id: z.string().length(21, 'Invalid post ID'),
|
|
76
|
+
published: z.boolean(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Schema for post query parameters
|
|
80
|
+
export const postQuerySchema = z.object({
|
|
81
|
+
page: z.coerce.number().int().positive().default(1),
|
|
82
|
+
limit: z.coerce.number().int().positive().max(100).default(10),
|
|
83
|
+
published: z
|
|
84
|
+
.string()
|
|
85
|
+
.transform((val) => val === 'true')
|
|
86
|
+
.optional(),
|
|
87
|
+
featured: z
|
|
88
|
+
.string()
|
|
89
|
+
.transform((val) => val === 'true')
|
|
90
|
+
.optional(),
|
|
91
|
+
authorId: z.string().optional(),
|
|
92
|
+
search: z.string().optional(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Comment validation schemas
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
// Schema for creating a new comment
|
|
100
|
+
export const createCommentSchema = z.object({
|
|
101
|
+
postId: z.string().length(21, 'Invalid post ID'),
|
|
102
|
+
|
|
103
|
+
content: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1, 'Comment content is required')
|
|
106
|
+
.max(2000, 'Comment must be 2,000 characters or less')
|
|
107
|
+
.trim(),
|
|
108
|
+
|
|
109
|
+
authorId: z.string().min(1, 'Author ID is required'),
|
|
110
|
+
|
|
111
|
+
authorName: z
|
|
112
|
+
.string()
|
|
113
|
+
.max(255, 'Author name must be 255 characters or less')
|
|
114
|
+
.optional()
|
|
115
|
+
.nullable(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Schema for updating a comment
|
|
119
|
+
export const updateCommentSchema = z.object({
|
|
120
|
+
id: z.string().length(21, 'Invalid comment ID'),
|
|
121
|
+
content: z
|
|
122
|
+
.string()
|
|
123
|
+
.min(1, 'Comment content is required')
|
|
124
|
+
.max(2000, 'Comment must be 2,000 characters or less')
|
|
125
|
+
.trim(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Schema for moderating a comment
|
|
129
|
+
export const moderateCommentSchema = z.object({
|
|
130
|
+
id: z.string().length(21, 'Invalid comment ID'),
|
|
131
|
+
approved: z.boolean().optional(),
|
|
132
|
+
flagged: z.boolean().optional(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Schema for comment query parameters
|
|
136
|
+
export const commentQuerySchema = z.object({
|
|
137
|
+
postId: z.string().length(21, 'Invalid post ID').optional(),
|
|
138
|
+
page: z.coerce.number().int().positive().default(1),
|
|
139
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
140
|
+
approved: z
|
|
141
|
+
.string()
|
|
142
|
+
.transform((val) => val === 'true')
|
|
143
|
+
.optional(),
|
|
144
|
+
authorId: z.string().optional(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Type exports for use in application
|
|
149
|
+
*/
|
|
150
|
+
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
|
151
|
+
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
|
152
|
+
export type PublishPostInput = z.infer<typeof publishPostSchema>;
|
|
153
|
+
export type PostQueryInput = z.infer<typeof postQuerySchema>;
|
|
154
|
+
|
|
155
|
+
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
|
156
|
+
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
|
157
|
+
export type ModerateCommentInput = z.infer<typeof moderateCommentSchema>;
|
|
158
|
+
export type CommentQueryInput = z.infer<typeof commentQuerySchema>;
|
package/app/src/env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference path="../.astro/types.d.ts" />
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Base Layout Component
|
|
4
|
+
* Demonstrates proper SCSS architecture:
|
|
5
|
+
* - No <style> tags in this file
|
|
6
|
+
* - All styles imported from external SCSS files
|
|
7
|
+
* - Global styles applied via import in the head
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { siteConfig } from '@/lib/config';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
pageTitle?: string | string[];
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
pageTitle,
|
|
19
|
+
description = siteConfig.stackDescription,
|
|
20
|
+
} = Astro.props;
|
|
21
|
+
|
|
22
|
+
const pageArray = Array.isArray(pageTitle) ? pageTitle : [pageTitle];
|
|
23
|
+
|
|
24
|
+
const firstElement = pageArray?.[0];
|
|
25
|
+
const shouldSkipFirst =
|
|
26
|
+
firstElement === '' ||
|
|
27
|
+
firstElement === siteConfig.stackName ||
|
|
28
|
+
firstElement?.toLowerCase() === 'index' ||
|
|
29
|
+
firstElement?.toLowerCase() === 'home';
|
|
30
|
+
|
|
31
|
+
const newPageTitle = (pageArray && pageArray.length > 0 && firstElement)
|
|
32
|
+
? shouldSkipFirst
|
|
33
|
+
? pageArray.filter((a, i) => i !== 0)
|
|
34
|
+
: pageArray
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
const title = newPageTitle ? `${newPageTitle.join(' | ')} | ${siteConfig.stackName}` : siteConfig.stackName;
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<!doctype html>
|
|
41
|
+
<html lang="en">
|
|
42
|
+
<head>
|
|
43
|
+
<meta charset="UTF-8" />
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
45
|
+
<meta name="description" content={description} />
|
|
46
|
+
<meta name="generator" content={Astro.generator} />
|
|
47
|
+
|
|
48
|
+
<!-- Favicons -->
|
|
49
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
50
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
51
|
+
|
|
52
|
+
<!-- PWA manifest -->
|
|
53
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
54
|
+
|
|
55
|
+
<title>{title}</title>
|
|
56
|
+
|
|
57
|
+
<!-- Global styles imported from external SCSS file -->
|
|
58
|
+
<link rel="stylesheet" href="/src/styles/global.scss" />
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<slot />
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Configuration
|
|
3
|
+
* Central configuration for the application that will be rendered on screen
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The core stack identifier - change this in ONE place to update everywhere
|
|
8
|
+
*/
|
|
9
|
+
const STACK_SHORT_NAME = 'ATSDC';
|
|
10
|
+
|
|
11
|
+
export const siteConfig = {
|
|
12
|
+
/**
|
|
13
|
+
* Short name for the stack (used in PWA and compact displays)
|
|
14
|
+
*/
|
|
15
|
+
stackShortName: STACK_SHORT_NAME,
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The full name of the stack/framework (derived from stackShortName)
|
|
19
|
+
*/
|
|
20
|
+
stackName: `${STACK_SHORT_NAME} Stack`,
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Full description of the stack
|
|
24
|
+
*/
|
|
25
|
+
stackDescription:
|
|
26
|
+
'Full-stack application built with Astro, TypeScript, Drizzle, Clerk, and SCSS',
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Docs URL
|
|
30
|
+
*/
|
|
31
|
+
docsUrl: 'https://github.com/adarshrkumar/The-ATSDC-Stack',
|
|
32
|
+
/**
|
|
33
|
+
* GitHub repository URL
|
|
34
|
+
*/
|
|
35
|
+
githubUrl: 'https://github.com/adarshrkumar/The-ATSDC-Stack',
|
|
36
|
+
} as const;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Converter Utilities
|
|
3
|
+
* Provides functions for converting between HTML and Markdown using marked and turndown
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { marked } from 'marked';
|
|
7
|
+
import TurndownService from 'turndown';
|
|
8
|
+
|
|
9
|
+
// Configure marked for markdown to HTML conversion
|
|
10
|
+
marked.setOptions({
|
|
11
|
+
gfm: true, // GitHub Flavored Markdown
|
|
12
|
+
breaks: true, // Convert \n to <br>
|
|
13
|
+
headerIds: true, // Add IDs to headings
|
|
14
|
+
mangle: false, // Don't escape email addresses
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Configure turndown for HTML to markdown conversion
|
|
18
|
+
const turndownService = new TurndownService({
|
|
19
|
+
headingStyle: 'atx', // Use # for headings
|
|
20
|
+
hr: '---', // Use --- for horizontal rules
|
|
21
|
+
bulletListMarker: '-', // Use - for bullet lists
|
|
22
|
+
codeBlockStyle: 'fenced', // Use ``` for code blocks
|
|
23
|
+
fence: '```', // Code fence marker
|
|
24
|
+
emDelimiter: '*', // Use * for emphasis
|
|
25
|
+
strongDelimiter: '**', // Use ** for strong
|
|
26
|
+
linkStyle: 'inlined', // Use [text](url) for links
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert Markdown to HTML
|
|
31
|
+
* @param markdown - The markdown string to convert
|
|
32
|
+
* @returns HTML string
|
|
33
|
+
*/
|
|
34
|
+
export async function markdownToHtml(markdown: string): Promise<string> {
|
|
35
|
+
try {
|
|
36
|
+
const html = await marked.parse(markdown);
|
|
37
|
+
return html;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Error converting markdown to HTML:', error);
|
|
40
|
+
throw new Error('Failed to convert markdown to HTML');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert Markdown to HTML synchronously
|
|
46
|
+
* @param markdown - The markdown string to convert
|
|
47
|
+
* @returns HTML string
|
|
48
|
+
*/
|
|
49
|
+
export function markdownToHtmlSync(markdown: string): string {
|
|
50
|
+
try {
|
|
51
|
+
const html = marked.parse(markdown) as string;
|
|
52
|
+
return html;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('Error converting markdown to HTML:', error);
|
|
55
|
+
throw new Error('Failed to convert markdown to HTML');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert HTML to Markdown
|
|
61
|
+
* @param html - The HTML string to convert
|
|
62
|
+
* @returns Markdown string
|
|
63
|
+
*/
|
|
64
|
+
export function htmlToMarkdown(html: string): string {
|
|
65
|
+
try {
|
|
66
|
+
const markdown = turndownService.turndown(html);
|
|
67
|
+
return markdown;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Error converting HTML to markdown:', error);
|
|
70
|
+
throw new Error('Failed to convert HTML to markdown');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sanitize and convert Markdown to HTML
|
|
76
|
+
* Useful for user-generated content
|
|
77
|
+
* @param markdown - The markdown string to convert
|
|
78
|
+
* @returns Sanitized HTML string
|
|
79
|
+
*/
|
|
80
|
+
export async function sanitizeMarkdown(markdown: string): Promise<string> {
|
|
81
|
+
try {
|
|
82
|
+
// Basic sanitization - remove script tags and dangerous attributes
|
|
83
|
+
const sanitized = markdown
|
|
84
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
85
|
+
.replace(/on\w+="[^"]*"/g, '')
|
|
86
|
+
.replace(/on\w+='[^']*'/g, '');
|
|
87
|
+
|
|
88
|
+
const html = await marked.parse(sanitized);
|
|
89
|
+
return html;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('Error sanitizing markdown:', error);
|
|
92
|
+
throw new Error('Failed to sanitize markdown');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract plain text from markdown
|
|
98
|
+
* @param markdown - The markdown string
|
|
99
|
+
* @returns Plain text string
|
|
100
|
+
*/
|
|
101
|
+
export function markdownToPlainText(markdown: string): string {
|
|
102
|
+
try {
|
|
103
|
+
const html = marked.parse(markdown) as string;
|
|
104
|
+
const text = html
|
|
105
|
+
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
|
106
|
+
.replace(/ /g, ' ') // Replace
|
|
107
|
+
.replace(/&/g, '&') // Replace &
|
|
108
|
+
.replace(/</g, '<') // Replace <
|
|
109
|
+
.replace(/>/g, '>') // Replace >
|
|
110
|
+
.replace(/"/g, '"') // Replace "
|
|
111
|
+
.trim();
|
|
112
|
+
return text;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Error extracting plain text:', error);
|
|
115
|
+
throw new Error('Failed to extract plain text');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate excerpt from markdown
|
|
121
|
+
* @param markdown - The markdown string
|
|
122
|
+
* @param maxLength - Maximum length of the excerpt (default: 200)
|
|
123
|
+
* @returns Excerpt string
|
|
124
|
+
*/
|
|
125
|
+
export function generateExcerpt(markdown: string, maxLength: number = 200): string {
|
|
126
|
+
const plainText = markdownToPlainText(markdown);
|
|
127
|
+
|
|
128
|
+
if (plainText.length <= maxLength) {
|
|
129
|
+
return plainText;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Truncate at word boundary
|
|
133
|
+
const truncated = plainText.slice(0, maxLength);
|
|
134
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
135
|
+
|
|
136
|
+
if (lastSpace > 0) {
|
|
137
|
+
return truncated.slice(0, lastSpace) + '...';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return truncated + '...';
|
|
141
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Manipulation Utilities
|
|
3
|
+
* Provides functions for manipulating HTML content using Cheerio
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as cheerio from 'cheerio';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract metadata from HTML content
|
|
10
|
+
* @param html - The HTML string to parse
|
|
11
|
+
* @returns Metadata object
|
|
12
|
+
*/
|
|
13
|
+
export function extractMetadata(html: string) {
|
|
14
|
+
const $ = cheerio.load(html);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
title: $('title').text() || $('h1').first().text() || '',
|
|
18
|
+
description: $('meta[name="description"]').attr('content') || '',
|
|
19
|
+
keywords: $('meta[name="keywords"]').attr('content') || '',
|
|
20
|
+
ogTitle: $('meta[property="og:title"]').attr('content') || '',
|
|
21
|
+
ogDescription: $('meta[property="og:description"]').attr('content') || '',
|
|
22
|
+
ogImage: $('meta[property="og:image"]').attr('content') || '',
|
|
23
|
+
twitterCard: $('meta[name="twitter:card"]').attr('content') || '',
|
|
24
|
+
canonical: $('link[rel="canonical"]').attr('href') || '',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract all links from HTML
|
|
30
|
+
* @param html - The HTML string to parse
|
|
31
|
+
* @returns Array of link objects
|
|
32
|
+
*/
|
|
33
|
+
export function extractLinks(html: string) {
|
|
34
|
+
const $ = cheerio.load(html);
|
|
35
|
+
const links: Array<{ text: string; href: string; title?: string }> = [];
|
|
36
|
+
|
|
37
|
+
$('a').each((_, element) => {
|
|
38
|
+
const $el = $(element);
|
|
39
|
+
const href = $el.attr('href');
|
|
40
|
+
if (href) {
|
|
41
|
+
links.push({
|
|
42
|
+
text: $el.text().trim(),
|
|
43
|
+
href,
|
|
44
|
+
title: $el.attr('title'),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return links;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract all images from HTML
|
|
54
|
+
* @param html - The HTML string to parse
|
|
55
|
+
* @returns Array of image objects
|
|
56
|
+
*/
|
|
57
|
+
export function extractImages(html: string) {
|
|
58
|
+
const $ = cheerio.load(html);
|
|
59
|
+
const images: Array<{ src: string; alt?: string; title?: string }> = [];
|
|
60
|
+
|
|
61
|
+
$('img').each((_, element) => {
|
|
62
|
+
const $el = $(element);
|
|
63
|
+
const src = $el.attr('src');
|
|
64
|
+
if (src) {
|
|
65
|
+
images.push({
|
|
66
|
+
src,
|
|
67
|
+
alt: $el.attr('alt'),
|
|
68
|
+
title: $el.attr('title'),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return images;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract headings from HTML
|
|
78
|
+
* @param html - The HTML string to parse
|
|
79
|
+
* @returns Array of heading objects
|
|
80
|
+
*/
|
|
81
|
+
export function extractHeadings(html: string) {
|
|
82
|
+
const $ = cheerio.load(html);
|
|
83
|
+
const headings: Array<{ level: number; text: string; id?: string }> = [];
|
|
84
|
+
|
|
85
|
+
$('h1, h2, h3, h4, h5, h6').each((_, element) => {
|
|
86
|
+
const $el = $(element);
|
|
87
|
+
const tagName = $el.prop('tagName')?.toLowerCase();
|
|
88
|
+
const level = parseInt(tagName?.replace('h', '') || '1');
|
|
89
|
+
|
|
90
|
+
headings.push({
|
|
91
|
+
level,
|
|
92
|
+
text: $el.text().trim(),
|
|
93
|
+
id: $el.attr('id'),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return headings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate table of contents from HTML
|
|
102
|
+
* @param html - The HTML string to parse
|
|
103
|
+
* @param maxLevel - Maximum heading level to include (default: 3)
|
|
104
|
+
* @returns Array of TOC items
|
|
105
|
+
*/
|
|
106
|
+
export function generateTableOfContents(html: string, maxLevel: number = 3) {
|
|
107
|
+
const headings = extractHeadings(html);
|
|
108
|
+
return headings
|
|
109
|
+
.filter((h) => h.level <= maxLevel)
|
|
110
|
+
.map((h) => ({
|
|
111
|
+
...h,
|
|
112
|
+
slug: h.id || h.text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Clean HTML content
|
|
118
|
+
* Removes scripts, styles, and other potentially dangerous elements
|
|
119
|
+
* @param html - The HTML string to clean
|
|
120
|
+
* @returns Cleaned HTML string
|
|
121
|
+
*/
|
|
122
|
+
export function cleanHtml(html: string): string {
|
|
123
|
+
const $ = cheerio.load(html);
|
|
124
|
+
|
|
125
|
+
// Remove dangerous elements
|
|
126
|
+
$('script, style, iframe, object, embed').remove();
|
|
127
|
+
|
|
128
|
+
// Remove event handlers
|
|
129
|
+
$('*').each((_, element) => {
|
|
130
|
+
const $el = $(element);
|
|
131
|
+
const attrs = $el.attr();
|
|
132
|
+
|
|
133
|
+
if (attrs) {
|
|
134
|
+
Object.keys(attrs).forEach((attr) => {
|
|
135
|
+
if (attr.startsWith('on')) {
|
|
136
|
+
$el.removeAttr(attr);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return $.html();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract text content from HTML
|
|
147
|
+
* @param html - The HTML string to parse
|
|
148
|
+
* @returns Plain text string
|
|
149
|
+
*/
|
|
150
|
+
export function extractTextContent(html: string): string {
|
|
151
|
+
const $ = cheerio.load(html);
|
|
152
|
+
|
|
153
|
+
// Remove script and style elements
|
|
154
|
+
$('script, style').remove();
|
|
155
|
+
|
|
156
|
+
return $('body').text().replace(/\s+/g, ' ').trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add IDs to headings for anchor linking
|
|
161
|
+
* @param html - The HTML string to process
|
|
162
|
+
* @returns HTML with IDs added to headings
|
|
163
|
+
*/
|
|
164
|
+
export function addHeadingIds(html: string): string {
|
|
165
|
+
const $ = cheerio.load(html);
|
|
166
|
+
|
|
167
|
+
$('h1, h2, h3, h4, h5, h6').each((_, element) => {
|
|
168
|
+
const $el = $(element);
|
|
169
|
+
|
|
170
|
+
if (!$el.attr('id')) {
|
|
171
|
+
const text = $el.text().trim();
|
|
172
|
+
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
|
173
|
+
$el.attr('id', id);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return $.html();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Add target="_blank" to external links
|
|
182
|
+
* @param html - The HTML string to process
|
|
183
|
+
* @param domain - The current domain (optional)
|
|
184
|
+
* @returns HTML with external links updated
|
|
185
|
+
*/
|
|
186
|
+
export function addExternalLinkTargets(html: string, domain?: string): string {
|
|
187
|
+
const $ = cheerio.load(html);
|
|
188
|
+
|
|
189
|
+
$('a').each((_, element) => {
|
|
190
|
+
const $el = $(element);
|
|
191
|
+
const href = $el.attr('href');
|
|
192
|
+
|
|
193
|
+
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
|
194
|
+
if (!domain || !href.includes(domain)) {
|
|
195
|
+
$el.attr('target', '_blank');
|
|
196
|
+
$el.attr('rel', 'noopener noreferrer');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return $.html();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Calculate reading time for HTML content
|
|
206
|
+
* @param html - The HTML string to analyze
|
|
207
|
+
* @param wordsPerMinute - Average reading speed (default: 200)
|
|
208
|
+
* @returns Reading time in minutes
|
|
209
|
+
*/
|
|
210
|
+
export function calculateReadingTime(html: string, wordsPerMinute: number = 200): number {
|
|
211
|
+
const text = extractTextContent(html);
|
|
212
|
+
const wordCount = text.split(/\s+/).length;
|
|
213
|
+
return Math.ceil(wordCount / wordsPerMinute);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Wrap tables in a responsive container
|
|
218
|
+
* @param html - The HTML string to process
|
|
219
|
+
* @returns HTML with tables wrapped
|
|
220
|
+
*/
|
|
221
|
+
export function wrapTables(html: string): string {
|
|
222
|
+
const $ = cheerio.load(html);
|
|
223
|
+
|
|
224
|
+
$('table').each((_, element) => {
|
|
225
|
+
const $el = $(element);
|
|
226
|
+
$el.wrap('<div class="table-wrapper" style="overflow-x: auto;"></div>');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return $.html();
|
|
230
|
+
}
|