mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			kev/share-
			...
			fix/mkdocs
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 66074ddbc9 | 
							
								
								
									
										7
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							| @@ -16,6 +16,7 @@ on: | ||||
|       - 'requirements-docs.txt' | ||||
|       - '.github/workflows/deploy-docs.yml' | ||||
|       - 'scripts/fix-mkdocs-structure.ts' | ||||
|       - 'validate-docs-links.ts' | ||||
|    | ||||
|   # Allow manual triggering from Actions tab | ||||
|   workflow_dispatch: | ||||
| @@ -32,6 +33,7 @@ on: | ||||
|       - 'requirements-docs.txt' | ||||
|       - '.github/workflows/deploy-docs.yml' | ||||
|       - 'scripts/fix-mkdocs-structure.ts' | ||||
|       - 'validate-docs-links.ts' | ||||
|  | ||||
| jobs: | ||||
|   build-and-deploy: | ||||
| @@ -111,6 +113,11 @@ jobs: | ||||
|           test -d site/assets || (echo "ERROR: site/assets directory not found" && exit 1) | ||||
|           echo "✅ Site validation passed" | ||||
|        | ||||
|       - name: Validate Documentation Links | ||||
|         run: | | ||||
|           # Run the TypeScript link validation script | ||||
|           pnpm tsx validate-docs-links.ts | ||||
|        | ||||
|       # Install wrangler globally to avoid workspace issues | ||||
|       - name: Install Wrangler | ||||
|         run: | | ||||
|   | ||||
| @@ -25,6 +25,7 @@ | ||||
|     "chore:update-build-info": "tsx ./scripts/update-build-info.ts", | ||||
|     "chore:update-version": "tsx ./scripts/update-version.ts", | ||||
|     "chore:fix-mkdocs-structure": "tsx ./scripts/fix-mkdocs-structure.ts", | ||||
|     "docs:validate-links": "tsx ./validate-docs-links.ts", | ||||
|     "edit-docs:edit-docs": "pnpm run --filter edit-docs edit-docs", | ||||
|     "edit-docs:edit-demo": "pnpm run --filter edit-docs edit-demo", | ||||
|     "test:all": "pnpm test:parallel && pnpm test:sequential", | ||||
|   | ||||
| @@ -109,8 +109,25 @@ function updateReferences(docsDir: string): FixResult[] { | ||||
|     const updatesMade: FixResult[] = []; | ||||
|      | ||||
|     function fixLink(match: string, text: string, link: string, currentDir: string, isIndex: boolean): string { | ||||
|         // Skip external links | ||||
|         if (link.startsWith('http')) { | ||||
|         // Skip external links, mailto, and special protocols | ||||
|         if (link.startsWith('http') || link.startsWith('mailto:') || link.startsWith('xmpp:')) { | ||||
|             return match; | ||||
|         } | ||||
|          | ||||
|         // Skip anchor-only links | ||||
|         if (link.startsWith('#')) { | ||||
|             return match; | ||||
|         } | ||||
|          | ||||
|         // Handle malformed links with nested brackets (e.g., [developers]([url](https://...)) | ||||
|         if (link.includes('[') || link.includes(']')) { | ||||
|             // This is a malformed link, skip it | ||||
|             return match; | ||||
|         } | ||||
|          | ||||
|         // Handle links wrapped in angle brackets (e.g., <https://...>) | ||||
|         if (link.startsWith('<') && link.endsWith('>')) { | ||||
|             // This is likely a literal URL that shouldn't be processed | ||||
|             return match; | ||||
|         } | ||||
|          | ||||
| @@ -123,6 +140,14 @@ function updateReferences(docsDir: string): FixResult[] { | ||||
|             decodedLink = link; | ||||
|         } | ||||
|          | ||||
|         // Extract anchor if present | ||||
|         let anchorPart = ''; | ||||
|         if (decodedLink.includes('#')) { | ||||
|             const parts = decodedLink.split('#'); | ||||
|             decodedLink = parts[0]; | ||||
|             anchorPart = '#' + parts.slice(1).join('#'); | ||||
|         } | ||||
|          | ||||
|         // Special case: if we're in index.md and the link starts with the parent directory name | ||||
|         // This happens when a file was converted to index.md and had links to siblings | ||||
|         if (isIndex && decodedLink.includes('/')) { | ||||
| @@ -136,7 +161,7 @@ function updateReferences(docsDir: string): FixResult[] { | ||||
|                 // Re-encode spaces for URL compatibility before recursing | ||||
|                 const fixedLinkEncoded = fixedLink.replace(/ /g, '%20'); | ||||
|                 // Recursively process the fixed link | ||||
|                 return fixLink(`[${text}](${fixedLinkEncoded})`, text, fixedLinkEncoded, currentDir, isIndex); | ||||
|                 return fixLink(`[${text}](${fixedLinkEncoded}${anchorPart})`, text, fixedLinkEncoded + anchorPart, currentDir, isIndex); | ||||
|             } | ||||
|         } | ||||
|          | ||||
| @@ -161,26 +186,43 @@ function updateReferences(docsDir: string): FixResult[] { | ||||
|                         if (path.dirname(potentialDir) === path.dirname(currentDir)) { | ||||
|                             // It's a sibling - just use directory name | ||||
|                             const dirName = path.basename(potentialDir).replace(/ /g, '%20'); | ||||
|                             return `[${text}](${dirName}/)`; | ||||
|                             return `[${text}](${dirName}/${anchorPart})`; | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     // Calculate relative path from current file to the directory | ||||
|                     const newPath = path.relative(currentDir, potentialDir).replace(/\\/g, '/').replace(/ /g, '%20'); | ||||
|                     return `[${text}](${newPath}/)`; | ||||
|                     return `[${text}](${newPath}/${anchorPart})`; | ||||
|                 } | ||||
|                  | ||||
|                 // Check if the target file exists | ||||
|                 if (!fs.existsSync(resolvedPath)) { | ||||
|                     // Try to find a similar file by removing special characters from the filename | ||||
|                     const dirPath = path.dirname(resolvedPath); | ||||
|                     const fileName = path.basename(resolvedPath); | ||||
|                      | ||||
|                     // Remove problematic characters and try to find the file | ||||
|                     const cleanFileName = fileName.replace(/[\(\)\\]/g, ''); | ||||
|                     const cleanPath = path.join(dirPath, cleanFileName); | ||||
|                      | ||||
|                     if (fs.existsSync(cleanPath)) { | ||||
|                         // Calculate relative path from current file to the cleaned file | ||||
|                         const newPath = path.relative(currentDir, cleanPath).replace(/\\/g, '/').replace(/ /g, '%20'); | ||||
|                         return `[${text}](${newPath}${anchorPart})`; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Also handle local references (same directory) | ||||
|         if (!decodedLink.includes('/')) { | ||||
|             const basename = decodedLink.slice(0, -3); // Remove .md extension | ||||
|             const basename = decodedLink.endsWith('.md') ? decodedLink.slice(0, -3) : decodedLink; | ||||
|             const possibleDir = path.join(currentDir, basename); | ||||
|              | ||||
|             if (fs.existsSync(possibleDir) && fs.statSync(possibleDir).isDirectory()) { | ||||
|                 // Re-encode spaces for URL compatibility | ||||
|                 const encodedBasename = basename.replace(/ /g, '%20'); | ||||
|                 return `[${text}](${encodedBasename}/)`; | ||||
|                 return `[${text}](${encodedBasename}/${anchorPart})`; | ||||
|             } | ||||
|         } | ||||
|          | ||||
| @@ -266,6 +308,20 @@ function syncReadmeToIndex(projectRoot: string, docsDir: string): FixResult[] { | ||||
|      | ||||
|     // Fix internal documentation links (./docs/User%20Guide -> ./User%20Guide) | ||||
|     content = content.replace(/\.\/docs\/User%20Guide/g, './User%20Guide'); | ||||
|     content = content.replace(/\.\/docs\/Script%20API/g, './Script%20API'); | ||||
|     content = content.replace(/\.\/docs\/Developer%20Guide/g, './Developer%20Guide'); | ||||
|      | ||||
|     // Fix specific broken links found in index.md | ||||
|     // These links point to non-existent files, so we need to fix them | ||||
|     content = content.replace(/User%20Guide\/quick-start\.md/g, 'User%20Guide/User%20Guide/'); | ||||
|     content = content.replace(/User%20Guide\/installation\.md/g, 'User%20Guide/User%20Guide/Installation%20&%20Setup/'); | ||||
|     content = content.replace(/User%20Guide\/docker\.md/g, 'User%20Guide/User%20Guide/Installation%20&%20Setup/Docker%20installation/'); | ||||
|     content = content.replace(/User%20Guide\/index\.md/g, 'User%20Guide/User%20Guide/'); | ||||
|     content = content.replace(/Script%20API\/index\.md/g, 'Script%20API/'); | ||||
|     content = content.replace(/Developer%20Guide\/index\.md/g, 'Developer%20Guide/Developer%20Guide/'); | ||||
|     content = content.replace(/Developer%20Guide\/contributing\.md/g, 'Developer%20Guide/Developer%20Guide/'); | ||||
|     content = content.replace(/support\/faq\.md/g, 'User%20Guide/User%20Guide/FAQ/'); | ||||
|     content = content.replace(/support\/troubleshooting\.md/g, 'User%20Guide/User%20Guide/'); | ||||
|      | ||||
|     // Write the adjusted content to docs/index.md | ||||
|     fs.writeFileSync(indexPath, content, 'utf-8'); | ||||
|   | ||||
							
								
								
									
										366
									
								
								validate-docs-links.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										366
									
								
								validate-docs-links.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,366 @@ | ||||
| #!/usr/bin/env tsx | ||||
|  | ||||
| import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; | ||||
| import { join, relative, dirname, resolve, extname } from 'path'; | ||||
| import { pathToFileURL } from 'url'; | ||||
|  | ||||
| interface LinkValidationResult { | ||||
|   file: string; | ||||
|   line: number; | ||||
|   link: string; | ||||
|   type: 'relative' | 'anchor' | 'absolute' | 'external'; | ||||
|   valid: boolean; | ||||
|   reason?: string; | ||||
|   targetFile?: string; | ||||
| } | ||||
|  | ||||
| class DocumentationLinkValidator { | ||||
|   private siteDir: string; | ||||
|   private sourceDir: string; | ||||
|   private results: LinkValidationResult[] = []; | ||||
|   private fileCache: Map<string, string> = new Map(); | ||||
|  | ||||
|   constructor(siteDir: string = './site', sourceDir: string = './docs') { | ||||
|     this.siteDir = resolve(siteDir); | ||||
|     this.sourceDir = resolve(sourceDir); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get all HTML files in the site directory | ||||
|    */ | ||||
|   private getAllHtmlFiles(dir: string): string[] { | ||||
|     const files: string[] = []; | ||||
|     const items = readdirSync(dir); | ||||
|  | ||||
|     for (const item of items) { | ||||
|       const fullPath = join(dir, item); | ||||
|       const stat = statSync(fullPath); | ||||
|  | ||||
|       if (stat.isDirectory()) { | ||||
|         files.push(...this.getAllHtmlFiles(fullPath)); | ||||
|       } else if (item.endsWith('.html')) { | ||||
|         files.push(fullPath); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return files; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get all markdown files in the docs directory | ||||
|    */ | ||||
|   private getAllMarkdownFiles(dir: string): string[] { | ||||
|     const files: string[] = []; | ||||
|     const items = readdirSync(dir); | ||||
|  | ||||
|     for (const item of items) { | ||||
|       const fullPath = join(dir, item); | ||||
|       const stat = statSync(fullPath); | ||||
|  | ||||
|       if (stat.isDirectory()) { | ||||
|         files.push(...this.getAllMarkdownFiles(fullPath)); | ||||
|       } else if (item.endsWith('.md')) { | ||||
|         files.push(fullPath); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return files; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract links from HTML content | ||||
|    */ | ||||
|   private extractHtmlLinks(content: string, filePath: string): Array<{link: string, line: number}> { | ||||
|     const links: Array<{link: string, line: number}> = []; | ||||
|     const lines = content.split('\n'); | ||||
|      | ||||
|     // Regex patterns for different types of links | ||||
|     const patterns = [ | ||||
|       /href=["']([^"']+)["']/g,  // href attributes | ||||
|       /src=["']([^"']+)["']/g,   // src attributes (for images, scripts, etc.) | ||||
|     ]; | ||||
|  | ||||
|     lines.forEach((line, index) => { | ||||
|       patterns.forEach(pattern => { | ||||
|         let match; | ||||
|         while ((match = pattern.exec(line)) !== null) { | ||||
|           const link = match[1]; | ||||
|           // Skip external links, mailto, javascript, and data URLs | ||||
|           if (!link.startsWith('http://') &&  | ||||
|               !link.startsWith('https://') &&  | ||||
|               !link.startsWith('mailto:') && | ||||
|               !link.startsWith('javascript:') && | ||||
|               !link.startsWith('data:') && | ||||
|               !link.startsWith('//')) { | ||||
|             links.push({ link, line: index + 1 }); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return links; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract links from Markdown content | ||||
|    */ | ||||
|   private extractMarkdownLinks(content: string, filePath: string): Array<{link: string, line: number}> { | ||||
|     const links: Array<{link: string, line: number}> = []; | ||||
|     const lines = content.split('\n'); | ||||
|      | ||||
|     // Regex patterns for markdown links | ||||
|     const patterns = [ | ||||
|       /\[([^\]]*)\]\(([^)]+)\)/g,  // [text](link) | ||||
|       /!\[([^\]]*)\]\(([^)]+)\)/g, //  | ||||
|     ]; | ||||
|  | ||||
|     lines.forEach((line, index) => { | ||||
|       patterns.forEach(pattern => { | ||||
|         let match; | ||||
|         while ((match = pattern.exec(line)) !== null) { | ||||
|           const link = match[2]; | ||||
|           // Skip external links | ||||
|           if (!link.startsWith('http://') &&  | ||||
|               !link.startsWith('https://') &&  | ||||
|               !link.startsWith('mailto:') && | ||||
|               !link.startsWith('//')) { | ||||
|             links.push({ link, line: index + 1 }); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return links; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate a single link | ||||
|    */ | ||||
|   private validateLink(link: string, sourceFile: string, isHtml: boolean = true): LinkValidationResult { | ||||
|     const baseResult = { | ||||
|       file: relative(process.cwd(), sourceFile), | ||||
|       link, | ||||
|       line: 0, | ||||
|     }; | ||||
|  | ||||
|     // Handle anchor links | ||||
|     if (link.startsWith('#')) { | ||||
|       return { | ||||
|         ...baseResult, | ||||
|         type: 'anchor', | ||||
|         valid: true, // We'll assume anchors are valid for now | ||||
|         reason: 'Anchor link (not validated)' | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Handle absolute paths | ||||
|     if (link.startsWith('/')) { | ||||
|       return { | ||||
|         ...baseResult, | ||||
|         type: 'absolute', | ||||
|         valid: false, | ||||
|         reason: 'Absolute paths are not recommended for relative documentation' | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Handle relative links | ||||
|     const sourceDir = dirname(sourceFile); | ||||
|     let targetPath: string; | ||||
|     let anchorPart = ''; | ||||
|  | ||||
|     // Split off anchor if present | ||||
|     const anchorIndex = link.indexOf('#'); | ||||
|     let linkPath = link; | ||||
|     if (anchorIndex > 0) { | ||||
|       linkPath = link.substring(0, anchorIndex); | ||||
|       anchorPart = link.substring(anchorIndex); | ||||
|     } | ||||
|  | ||||
|     // Decode URL-encoded characters | ||||
|     linkPath = decodeURIComponent(linkPath); | ||||
|  | ||||
|     if (isHtml) { | ||||
|       // For HTML files in site directory | ||||
|       targetPath = resolve(sourceDir, linkPath); | ||||
|        | ||||
|       // Check if it's a directory link (should have index.html) | ||||
|       if (!linkPath.endsWith('.html') && !linkPath.endsWith('/')) { | ||||
|         // Try with .html extension | ||||
|         const htmlPath = targetPath + '.html'; | ||||
|         if (existsSync(htmlPath)) { | ||||
|           targetPath = htmlPath; | ||||
|         } else { | ||||
|           // Try as directory with index.html | ||||
|           const indexPath = join(targetPath, 'index.html'); | ||||
|           if (existsSync(indexPath)) { | ||||
|             targetPath = indexPath; | ||||
|           } | ||||
|         } | ||||
|       } else if (linkPath.endsWith('/')) { | ||||
|         targetPath = join(targetPath, 'index.html'); | ||||
|       } | ||||
|     } else { | ||||
|       // For markdown files in docs directory | ||||
|       targetPath = resolve(sourceDir, linkPath); | ||||
|        | ||||
|       // MkDocs converts .md to .html, so we need to check both | ||||
|       if (linkPath.endsWith('.md')) { | ||||
|         // Check if the .md file exists | ||||
|         if (!existsSync(targetPath)) { | ||||
|           // Try without .md and with various extensions | ||||
|           const basePath = targetPath.slice(0, -3); | ||||
|           if (existsSync(basePath + '.md')) { | ||||
|             targetPath = basePath + '.md'; | ||||
|           } | ||||
|         } | ||||
|       } else if (!extname(linkPath)) { | ||||
|         // No extension, could be a directory or file | ||||
|         if (existsSync(targetPath + '.md')) { | ||||
|           targetPath = targetPath + '.md'; | ||||
|         } else if (existsSync(join(targetPath, 'index.md'))) { | ||||
|           targetPath = join(targetPath, 'index.md'); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const exists = existsSync(targetPath); | ||||
|  | ||||
|     return { | ||||
|       ...baseResult, | ||||
|       type: 'relative', | ||||
|       valid: exists, | ||||
|       reason: exists ? undefined : `Target file not found: ${relative(process.cwd(), targetPath)}`, | ||||
|       targetFile: exists ? relative(process.cwd(), targetPath) : undefined | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate all links in HTML files | ||||
|    */ | ||||
|   public validateHtmlFiles(): void { | ||||
|     console.log(`\n🔍 Validating HTML files in ${this.siteDir}...\n`); | ||||
|      | ||||
|     if (!existsSync(this.siteDir)) { | ||||
|       console.error(`❌ Site directory not found: ${this.siteDir}`); | ||||
|       console.log('Please run "mkdocs build" first to generate the site.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const htmlFiles = this.getAllHtmlFiles(this.siteDir); | ||||
|     console.log(`Found ${htmlFiles.length} HTML files to validate.\n`); | ||||
|  | ||||
|     for (const file of htmlFiles) { | ||||
|       const content = readFileSync(file, 'utf-8'); | ||||
|       const links = this.extractHtmlLinks(content, file); | ||||
|  | ||||
|       for (const { link, line } of links) { | ||||
|         const result = this.validateLink(link, file, true); | ||||
|         result.line = line; | ||||
|         if (!result.valid) { | ||||
|           this.results.push(result); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate all links in Markdown files | ||||
|    */ | ||||
|   public validateMarkdownFiles(): void { | ||||
|     console.log(`\n🔍 Validating Markdown files in ${this.sourceDir}...\n`); | ||||
|      | ||||
|     if (!existsSync(this.sourceDir)) { | ||||
|       console.error(`❌ Docs directory not found: ${this.sourceDir}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const mdFiles = this.getAllMarkdownFiles(this.sourceDir); | ||||
|     console.log(`Found ${mdFiles.length} Markdown files to validate.\n`); | ||||
|  | ||||
|     for (const file of mdFiles) { | ||||
|       const content = readFileSync(file, 'utf-8'); | ||||
|       const links = this.extractMarkdownLinks(content, file); | ||||
|  | ||||
|       for (const { link, line } of links) { | ||||
|         const result = this.validateLink(link, file, false); | ||||
|         result.line = line; | ||||
|         if (!result.valid) { | ||||
|           this.results.push(result); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Print validation results | ||||
|    */ | ||||
|   public printResults(): void { | ||||
|     if (this.results.length === 0) { | ||||
|       console.log('✅ All links are valid!\n'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     console.log(`\n❌ Found ${this.results.length} broken links:\n`); | ||||
|     console.log('=' .repeat(80)); | ||||
|  | ||||
|     // Group results by file | ||||
|     const resultsByFile = new Map<string, LinkValidationResult[]>(); | ||||
|     for (const result of this.results) { | ||||
|       if (!resultsByFile.has(result.file)) { | ||||
|         resultsByFile.set(result.file, []); | ||||
|       } | ||||
|       resultsByFile.get(result.file)!.push(result); | ||||
|     } | ||||
|  | ||||
|     // Print results grouped by file | ||||
|     for (const [file, fileResults] of resultsByFile) { | ||||
|       console.log(`\n📄 ${file}`); | ||||
|       console.log('-'.repeat(80)); | ||||
|        | ||||
|       for (const result of fileResults) { | ||||
|         console.log(`  Line ${result.line}: ${result.link}`); | ||||
|         if (result.reason) { | ||||
|           console.log(`    ⚠️  ${result.reason}`); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     console.log('\n' + '='.repeat(80)); | ||||
|     console.log(`\nTotal: ${this.results.length} broken links found.`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get validation results for programmatic use | ||||
|    */ | ||||
|   public getResults(): LinkValidationResult[] { | ||||
|     return this.results; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Run full validation | ||||
|    */ | ||||
|   public validate(): boolean { | ||||
|     this.results = []; | ||||
|      | ||||
|     // Validate built HTML files | ||||
|     this.validateHtmlFiles(); | ||||
|      | ||||
|     // Also validate source markdown files for better debugging | ||||
|     this.validateMarkdownFiles(); | ||||
|      | ||||
|     this.printResults(); | ||||
|      | ||||
|     return this.results.length === 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Main execution | ||||
| if (import.meta.url === pathToFileURL(process.argv[1]).href) { | ||||
|   const validator = new DocumentationLinkValidator(); | ||||
|   const isValid = validator.validate(); | ||||
|    | ||||
|   // Exit with error code if links are broken | ||||
|   process.exit(isValid ? 0 : 1); | ||||
| } | ||||
|  | ||||
| export { DocumentationLinkValidator }; | ||||
		Reference in New Issue
	
	Block a user