Add Go CLI with container expansion parser and development server
- Implement Go-based CLI for build-time HTML enhancement - Add container expansion: div.insertr auto-expands to viable children - Create servedev command with live development workflow - Add Air configuration for automatic rebuilds and serving - Enable transition from runtime JS to build-time enhancement approach
This commit is contained in:
551
RESEARCH.md
Normal file
551
RESEARCH.md
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
# CMS Approaches and Workflows: Comprehensive Research
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document analyzes different Content Management System (CMS) approaches, their workflows, and architectural patterns to inform the strategic direction of Insertr CMS. Through extensive research and strategic iteration, we've identified a unique positioning as "The Tailwind of CMS" - an HTML enhancement service that adds editing capabilities to any static site without architectural changes.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [CMS Architecture Patterns](#cms-architecture-patterns)
|
||||||
|
2. [Content Workflow Analysis](#content-workflow-analysis)
|
||||||
|
3. [Rebuild Strategy Comparison](#rebuild-strategy-comparison)
|
||||||
|
4. [Strategic Pivot: HTML Enhancement Approach](#strategic-pivot-html-enhancement-approach)
|
||||||
|
5. [Competitive Landscape](#competitive-landscape)
|
||||||
|
6. [Market Positioning: "The Tailwind of CMS"](#market-positioning-the-tailwind-of-cms)
|
||||||
|
7. [Technical Implementation](#technical-implementation)
|
||||||
|
8. [Strategic Recommendations](#strategic-recommendations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CMS Architecture Patterns
|
||||||
|
|
||||||
|
### 1. Traditional CMS (WordPress, Drupal)
|
||||||
|
**Architecture**: Monolithic, database-driven, server-side rendering
|
||||||
|
**Content Flow**: Database → Server → HTML → Browser
|
||||||
|
**Deployment**: Server hosting, automatic updates, runtime content changes
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Immediate content updates
|
||||||
|
- Rich ecosystem of plugins/themes
|
||||||
|
- Non-technical user friendly
|
||||||
|
- Mature tooling and community
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Security vulnerabilities
|
||||||
|
- Performance bottlenecks
|
||||||
|
- Server maintenance overhead
|
||||||
|
- Vendor lock-in to hosting stack
|
||||||
|
|
||||||
|
**Example Workflow:**
|
||||||
|
```
|
||||||
|
Editor → WordPress Admin → Database → Live Site
|
||||||
|
(Immediate, no build process)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Headless/API-First CMS (Contentful, Strapi, Sanity)
|
||||||
|
**Architecture**: API backend + Frontend application
|
||||||
|
**Content Flow**: API Database → GraphQL/REST → Static Build/Runtime → Browser
|
||||||
|
**Deployment**: Separate API server + Static/Dynamic frontend
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Flexible frontend technology choices
|
||||||
|
- Scalable API architecture
|
||||||
|
- Multi-channel content delivery
|
||||||
|
- Developer-friendly GraphQL/REST APIs
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Complex setup and configuration
|
||||||
|
- API dependency for content
|
||||||
|
- Runtime performance considerations
|
||||||
|
- Higher technical complexity
|
||||||
|
|
||||||
|
**Example Workflow:**
|
||||||
|
```
|
||||||
|
Editor → Headless CMS Admin → API → Webhook → Build Process → Static Site
|
||||||
|
(Build-dependent, but flexible)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Git-Based CMS (Decap, Forestry, CloudCannon, TinaCMS)
|
||||||
|
**Architecture**: Git repository as content database + Admin interface
|
||||||
|
**Content Flow**: Git Files → Admin UI → Git Commits → Static Site Generator → Browser
|
||||||
|
**Deployment**: Git-triggered builds, static site deployment
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Version control for content
|
||||||
|
- Developer-friendly Git workflow
|
||||||
|
- Static site performance benefits
|
||||||
|
- Backup and rollback capabilities
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Rebuild requirements for changes
|
||||||
|
- Git complexity for non-technical users
|
||||||
|
- Limited real-time collaboration
|
||||||
|
- Build time delays
|
||||||
|
- **File mapping complexity**
|
||||||
|
|
||||||
|
**Example Workflow:**
|
||||||
|
```
|
||||||
|
Editor → Git-based Admin → Git Commit → Build Trigger → Static Site
|
||||||
|
(Git-dependent, build-required)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Visual/Page Builder CMS (Webflow, Wix, Builder.io)
|
||||||
|
**Architecture**: Visual interface + Hosted rendering engine
|
||||||
|
**Content Flow**: Visual Editor → Proprietary Database → Rendered Pages → Browser
|
||||||
|
**Deployment**: Platform-hosted, automatic deployment
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- No-code visual editing
|
||||||
|
- Immediate visual feedback
|
||||||
|
- Built-in hosting and optimization
|
||||||
|
- Designer-friendly interface
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Vendor lock-in
|
||||||
|
- Limited customization depth
|
||||||
|
- Export/migration difficulties
|
||||||
|
- Higher costs for custom needs
|
||||||
|
|
||||||
|
### 5. **HTML Enhancement Pattern (Insertr's Approach)**
|
||||||
|
**Architecture**: Build-time content injection + Conditional editor loading
|
||||||
|
**Content Flow**: Static HTML → Enhancement CLI → Database Content → Enhanced Site
|
||||||
|
**Deployment**: Standard static site deployment with editing capabilities
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Zero runtime overhead for visitors
|
||||||
|
- Works with any static site generator
|
||||||
|
- No architectural changes required
|
||||||
|
- Database-backed content storage
|
||||||
|
- Framework-agnostic approach
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Build-dependent for content updates
|
||||||
|
- Requires enhancement step in build process
|
||||||
|
|
||||||
|
**Example Workflow:**
|
||||||
|
```
|
||||||
|
Static Site Build → Insertr Enhancement CLI → Enhanced HTML → Deploy
|
||||||
|
↓
|
||||||
|
Database Content Injection + Editor Loading
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Workflow Analysis
|
||||||
|
|
||||||
|
### Immediate Update Pattern
|
||||||
|
**Used by**: WordPress, Webflow, Traditional CMS
|
||||||
|
**Flow**: Edit → Save → Live (0-5 seconds)
|
||||||
|
**Benefits**: Instant feedback, simple mental model
|
||||||
|
**Drawbacks**: No review process, potential for mistakes
|
||||||
|
|
||||||
|
### Build-Dependent Pattern
|
||||||
|
**Used by**: Gatsby + Contentful, Decap CMS, Hugo workflows
|
||||||
|
**Flow**: Edit → Save → Build → Deploy (2-10 minutes)
|
||||||
|
**Benefits**: Review process, optimized output, version control
|
||||||
|
**Drawbacks**: Slow feedback loop, build complexity
|
||||||
|
|
||||||
|
### Enhancement Pattern (Insertr's Approach)
|
||||||
|
**Flow**: Edit (cached) → Batch Publish → Enhanced Rebuild → Deploy
|
||||||
|
**Benefits**:
|
||||||
|
- Static performance for visitors
|
||||||
|
- Rich editing experience for editors
|
||||||
|
- Batched updates reduce build frequency
|
||||||
|
- Zero configuration required
|
||||||
|
**Drawbacks**: 2-5 minute delay for content changes to go live
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rebuild Strategy Comparison
|
||||||
|
|
||||||
|
### The "Decap Problem": Every Edit Triggers Rebuild
|
||||||
|
**Issue**: Each content change commits to Git → triggers full site rebuild
|
||||||
|
**Impact**:
|
||||||
|
- Slow feedback (2-10 minute delays)
|
||||||
|
- Expensive build resources
|
||||||
|
- Poor user experience for iterative editing
|
||||||
|
- CI/CD queue bottlenecks
|
||||||
|
- **File mapping configuration complexity**
|
||||||
|
|
||||||
|
### Insertr's Solution: Build-Time Enhancement
|
||||||
|
**Strategy**:
|
||||||
|
1. Static site builds normally (Hugo, Next.js, etc.)
|
||||||
|
2. Enhancement CLI reads built HTML + fetches latest content
|
||||||
|
3. CLI injects current content into HTML + adds editing hooks
|
||||||
|
4. Deploy enhanced static site
|
||||||
|
5. Editors trigger batched rebuilds only when publishing
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Eliminates file mapping complexity
|
||||||
|
- Reduces rebuild frequency through batching
|
||||||
|
- Maintains pure static performance
|
||||||
|
- Works with any SSG without configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Pivot: HTML Enhancement Approach
|
||||||
|
|
||||||
|
### Core Insight: Avoid File/Git Mapping Complexity
|
||||||
|
|
||||||
|
**Problem with Git-Based Approach**:
|
||||||
|
- Mapping `data-content-id` to files/fields requires configuration
|
||||||
|
- Different SSGs have different file conventions
|
||||||
|
- Merge conflicts in collaborative editing
|
||||||
|
- Schema drift as files change
|
||||||
|
- Framework coupling issues
|
||||||
|
|
||||||
|
**Solution: Database + Build-Time Injection**:
|
||||||
|
- Simple key-value storage: `content_id → content_value`
|
||||||
|
- No file mapping needed
|
||||||
|
- Framework agnostic
|
||||||
|
- Conflict-free collaborative editing
|
||||||
|
- Audit trails and change tracking
|
||||||
|
|
||||||
|
### New Architecture: "Editing as a Service"
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Static Site │ │ Enhancement CLI │ │ Enhanced │
|
||||||
|
│ Generator │ │ │ │ Deployment │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ Hugo/Next.js/ │───▶│ Parse HTML │───▶│ Static Site │
|
||||||
|
│ Jekyll/etc. │ │ Inject Content │ │ + Editing │
|
||||||
|
│ │ │ Add Editor Hooks │ │ + Smart Loading │
|
||||||
|
│ Outputs: │ │ │ │ │
|
||||||
|
│ Pure HTML │ │ Database ←→ API │ │ Zero Overhead │
|
||||||
|
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHub Actions Integration Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy.yml
|
||||||
|
name: Build and Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# 1. Standard static site build
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Build static site
|
||||||
|
run: hugo --minify
|
||||||
|
|
||||||
|
# 2. Enhance with Insertr
|
||||||
|
- name: Add editing capabilities
|
||||||
|
run: npx @insertr/enhance ./public --output ./dist --api-url ${{ secrets.INSERTR_API_URL }}
|
||||||
|
|
||||||
|
# 3. Deploy enhanced site
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
publish_dir: ./dist
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Competitive Landscape
|
||||||
|
|
||||||
|
### Market Leaders Analysis
|
||||||
|
|
||||||
|
#### CloudCannon
|
||||||
|
**Position**: Enterprise Git-based CMS
|
||||||
|
**Strengths**:
|
||||||
|
- Mature visual editing
|
||||||
|
- Multi-SSG support
|
||||||
|
- Agency-focused features
|
||||||
|
- Robust hosting integration
|
||||||
|
|
||||||
|
**Weaknesses**:
|
||||||
|
- Complex setup process (days of configuration)
|
||||||
|
- Expensive pricing model ($45+/month)
|
||||||
|
- Opinionated workflow requirements
|
||||||
|
- Heavy platform lock-in
|
||||||
|
- Component-based editing only
|
||||||
|
|
||||||
|
**Target Market**: Digital agencies, enterprise teams, complex multi-site setups
|
||||||
|
|
||||||
|
#### TinaCMS
|
||||||
|
**Position**: Git + API hybrid, developer-friendly
|
||||||
|
**Strengths**:
|
||||||
|
- React-based editing
|
||||||
|
- Git workflow with API preview
|
||||||
|
- Open source with cloud offering
|
||||||
|
- Developer-centric approach
|
||||||
|
|
||||||
|
**Weaknesses**:
|
||||||
|
- React/Next.js focused (limited framework support)
|
||||||
|
- Still requires significant setup
|
||||||
|
- Complex architecture
|
||||||
|
- Schema configuration required
|
||||||
|
|
||||||
|
**Target Market**: React developers, Next.js sites, developer-first teams
|
||||||
|
|
||||||
|
#### Decap CMS (formerly Netlify CMS)
|
||||||
|
**Position**: Simple Git-based editing
|
||||||
|
**Strengths**:
|
||||||
|
- Easy setup
|
||||||
|
- Framework agnostic
|
||||||
|
- Open source
|
||||||
|
- Simple mental model
|
||||||
|
|
||||||
|
**Weaknesses**:
|
||||||
|
- Poor editing UX (form-based)
|
||||||
|
- Constant rebuild issue
|
||||||
|
- Limited customization
|
||||||
|
- Maintenance mode concerns
|
||||||
|
|
||||||
|
**Target Market**: Individual developers, simple blogs, basic static sites
|
||||||
|
|
||||||
|
### Market Gap Analysis
|
||||||
|
|
||||||
|
#### Under-Served Segments:
|
||||||
|
1. **Existing Static Sites**: Need editing without architectural changes
|
||||||
|
2. **Budget-Conscious Developers**: Need CMS features without enterprise costs
|
||||||
|
3. **Framework-Agnostic Teams**: Want editing that works with any tech stack
|
||||||
|
4. **Rapid Integration**: Need editing capabilities added in minutes, not days
|
||||||
|
5. **Element-Level Editing**: Granular control over specific content elements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market Positioning: "The Tailwind of CMS"
|
||||||
|
|
||||||
|
### Philosophy Parallel
|
||||||
|
|
||||||
|
**Tailwind CSS Approach**:
|
||||||
|
- Utility-first classes: `class="text-lg font-bold"`
|
||||||
|
- Build-time optimization (PurgeCSS)
|
||||||
|
- Framework agnostic
|
||||||
|
- Developer workflow integration
|
||||||
|
- Zero runtime overhead
|
||||||
|
|
||||||
|
**Insertr CMS Approach**:
|
||||||
|
- Content-first classes: `class="insertr"`
|
||||||
|
- Build-time enhancement (content injection)
|
||||||
|
- Framework agnostic
|
||||||
|
- Developer workflow integration
|
||||||
|
- Zero runtime overhead for visitors
|
||||||
|
|
||||||
|
### Shared Principles
|
||||||
|
|
||||||
|
#### 1. Zero Configuration Philosophy
|
||||||
|
**Tailwind**: No CSS files, everything in HTML
|
||||||
|
**Insertr**: No schema files, everything in HTML markup
|
||||||
|
|
||||||
|
#### 2. Build-Time Optimization
|
||||||
|
**Tailwind**: Unused styles purged at build time
|
||||||
|
**Insertr**: Content injected and editor bundled at build time
|
||||||
|
|
||||||
|
#### 3. Developer Experience Focus
|
||||||
|
**Tailwind**: Stay in markup, don't context switch to CSS files
|
||||||
|
**Insertr**: Stay in markup, don't context switch to CMS admin panels
|
||||||
|
|
||||||
|
#### 4. Framework Agnostic
|
||||||
|
**Tailwind**: Works with React, Vue, vanilla HTML, etc.
|
||||||
|
**Insertr**: Works with Hugo, Next.js, Jekyll, etc.
|
||||||
|
|
||||||
|
### Value Propositions
|
||||||
|
|
||||||
|
#### For Developers:
|
||||||
|
- **"Rapidly add editing to any website without ever leaving your markup"**
|
||||||
|
- 5-minute setup vs days of CMS configuration
|
||||||
|
- Works with existing design systems and frameworks
|
||||||
|
- No architectural changes required
|
||||||
|
|
||||||
|
#### For Content Editors:
|
||||||
|
- Edit content directly on the live site
|
||||||
|
- No forms, no admin panels - just click and edit
|
||||||
|
- See changes exactly as visitors will
|
||||||
|
|
||||||
|
#### For Agencies:
|
||||||
|
- Turn any static site into an editable website for clients
|
||||||
|
- No CMS migration needed - enhance existing sites
|
||||||
|
- Client-friendly editing without developer complexity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Auto-Generated Content IDs
|
||||||
|
|
||||||
|
**Problem**: Manual `data-content-id` adds friction
|
||||||
|
**Solution**: Automatic ID generation at build time
|
||||||
|
|
||||||
|
**Developer Writes**:
|
||||||
|
```html
|
||||||
|
<h1 class="insertr">Welcome to Our Site</h1>
|
||||||
|
<p class="insertr">This is editable content</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enhancement CLI Generates**:
|
||||||
|
```html
|
||||||
|
<h1 class="insertr" data-content-id="hero-welcome-title">
|
||||||
|
Latest Title From Database
|
||||||
|
</h1>
|
||||||
|
<p class="insertr" data-content-id="hero-description">
|
||||||
|
Latest Description From Database
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Loading for Performance
|
||||||
|
|
||||||
|
**For Regular Visitors (99% of traffic)**:
|
||||||
|
- Zero editor assets loaded
|
||||||
|
- Pure static HTML performance
|
||||||
|
- Tiny auth check (~1KB)
|
||||||
|
|
||||||
|
**For Authenticated Editors**:
|
||||||
|
- Rich editing UI loads on demand
|
||||||
|
- Full feature set available
|
||||||
|
- Real-time editing experience
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Only load editor for authenticated users
|
||||||
|
if (await insertr.isAuthenticated()) {
|
||||||
|
await insertr.loadEditor();
|
||||||
|
insertr.activateEditing();
|
||||||
|
}
|
||||||
|
// Otherwise: zero overhead
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Storage Architecture
|
||||||
|
|
||||||
|
**Simple Database Model**:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE content (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
site_id VARCHAR(255) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL DEFAULT 'text',
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**No File Mapping Required**:
|
||||||
|
- Direct content_id → value lookup
|
||||||
|
- No configuration files needed
|
||||||
|
- Framework agnostic storage
|
||||||
|
- Simple API: GET/PUT `/api/content/:id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Recommendations
|
||||||
|
|
||||||
|
### 1. Market Positioning: "The Tailwind of CMS"
|
||||||
|
|
||||||
|
**Target Personas**:
|
||||||
|
- Full-stack developers building client sites
|
||||||
|
- Frontend developers adding editing to existing sites
|
||||||
|
- Agencies needing client-friendly editing solutions
|
||||||
|
- Budget-conscious teams avoiding enterprise CMS costs
|
||||||
|
|
||||||
|
**Core Message**: "Zero configuration content editing for any static site"
|
||||||
|
|
||||||
|
### 2. Technical Architecture: HTML Enhancement Service
|
||||||
|
|
||||||
|
**Core Components**:
|
||||||
|
|
||||||
|
1. **Enhancement CLI**:
|
||||||
|
- Parse HTML and identify editable elements
|
||||||
|
- Fetch latest content from database
|
||||||
|
- Inject content and editing capabilities
|
||||||
|
- Generate optimized bundles
|
||||||
|
|
||||||
|
2. **Content Storage API**:
|
||||||
|
- Simple REST API for content CRUD
|
||||||
|
- Database-backed (PostgreSQL/SQLite)
|
||||||
|
- Multi-site content isolation
|
||||||
|
- Authentication integration
|
||||||
|
|
||||||
|
3. **Smart Loading System**:
|
||||||
|
- Conditional editor asset loading
|
||||||
|
- Zero overhead for regular visitors
|
||||||
|
- Rich editing experience for authenticated users
|
||||||
|
|
||||||
|
### 3. Differentiation Strategy
|
||||||
|
|
||||||
|
#### Against CloudCannon:
|
||||||
|
- **Simplicity**: `class="insertr"` vs complex component schemas
|
||||||
|
- **Setup Time**: 5 minutes vs days of configuration
|
||||||
|
- **Pricing**: Developer-friendly vs enterprise focus
|
||||||
|
- **Flexibility**: Works with any design vs rigid components
|
||||||
|
|
||||||
|
#### Against TinaCMS:
|
||||||
|
- **Framework Agnostic**: Any SSG vs React/Next.js focus
|
||||||
|
- **Zero Config**: Auto-generated IDs vs schema requirements
|
||||||
|
- **Lightweight**: Focused enhancement vs full platform
|
||||||
|
|
||||||
|
#### Against Decap CMS:
|
||||||
|
- **Better UX**: Rich in-place editing vs basic forms
|
||||||
|
- **Smart Rebuilds**: Batched updates vs every commit
|
||||||
|
- **Active Development**: Modern architecture vs maintenance mode
|
||||||
|
|
||||||
|
### 4. Implementation Roadmap
|
||||||
|
|
||||||
|
#### Phase 3a: Enhancement Engine (4-6 weeks)
|
||||||
|
- CLI tool for HTML parsing and content injection
|
||||||
|
- Auto-generated content ID system
|
||||||
|
- Database-backed content storage API
|
||||||
|
- Smart asset loading implementation
|
||||||
|
|
||||||
|
#### Phase 3b: Integration Examples (2-3 weeks)
|
||||||
|
- GitHub Actions integration
|
||||||
|
- Hugo/Jekyll/Next.js examples
|
||||||
|
- Netlify/Vercel deployment guides
|
||||||
|
- Performance optimization documentation
|
||||||
|
|
||||||
|
#### Phase 3c: Developer Experience (3-4 weeks)
|
||||||
|
- Comprehensive documentation
|
||||||
|
- Interactive setup guides
|
||||||
|
- Video tutorials and examples
|
||||||
|
- Community building initiatives
|
||||||
|
|
||||||
|
### 5. Success Metrics
|
||||||
|
|
||||||
|
**Developer Adoption**:
|
||||||
|
- Setup time: **< 5 minutes** (vs hours/days for competitors)
|
||||||
|
- Performance impact: **0% for visitors** (pure static delivery)
|
||||||
|
- Integration complexity: **Single CLI command**
|
||||||
|
|
||||||
|
**User Experience**:
|
||||||
|
- Edit-to-live time: **2-5 minutes** (build-dependent but acceptable)
|
||||||
|
- Learning curve: **< 5 minutes** for content editors
|
||||||
|
- Editor loading time: **< 2 seconds** for authenticated users
|
||||||
|
|
||||||
|
### 6. Go-to-Market Strategy
|
||||||
|
|
||||||
|
#### Developer-First Approach:
|
||||||
|
1. **Open Source Core**: Build community credibility
|
||||||
|
2. **Exceptional Documentation**: Focus on developer experience
|
||||||
|
3. **Framework Integrations**: Support popular SSGs out of the box
|
||||||
|
4. **Community Building**: Developer advocates, tutorials, examples
|
||||||
|
|
||||||
|
#### Pricing Strategy:
|
||||||
|
```
|
||||||
|
Free Tier: Open source, self-hosted, unlimited sites
|
||||||
|
Hosted Tier: $9/month, hosted API, basic features
|
||||||
|
Pro Tier: $29/month, advanced features, priority support
|
||||||
|
Agency Tier: $99/month, multi-site management, white-label
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The strategic pivot to an "HTML Enhancement" approach positions Insertr uniquely in the CMS market. By embracing the "Tailwind philosophy" of utility-first, build-time optimization, and zero configuration, we can capture the under-served segment of developers who want powerful editing capabilities without architectural complexity.
|
||||||
|
|
||||||
|
Key advantages of this approach:
|
||||||
|
|
||||||
|
1. **Simplicity**: Eliminates file mapping and Git workflow complexity
|
||||||
|
2. **Performance**: Zero runtime overhead for visitors, static site benefits maintained
|
||||||
|
3. **Flexibility**: Works with any static site generator or HTML output
|
||||||
|
4. **Developer Experience**: 5-minute setup vs days of traditional CMS configuration
|
||||||
|
5. **Market Differentiation**: Unique positioning against component-heavy competitors
|
||||||
|
|
||||||
|
The modular architecture developed in earlier phases provides the perfect foundation for this enhancement-service approach. By focusing on being the best possible editing experience that works with any content system, rather than trying to solve the entire CMS workflow, Insertr can achieve sustainable growth in the developer tools market.
|
||||||
|
|
||||||
|
This "editing as a service" model represents the future of content management for static sites - lightweight, performant, and developer-friendly.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Acme Consulting Services</title>
|
<title>Acme Consulting Services - Live Reload Test</title>
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
<link rel="stylesheet" href="insertr/insertr.css">
|
<link rel="stylesheet" href="insertr/insertr.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
46
insertr-cli/.air.toml
Normal file
46
insertr-cli/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/insertr"
|
||||||
|
cmd = "go build -o ./tmp/insertr ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = "./tmp/insertr servedev -i ../demo-site -p 3000"
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_root = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
71
insertr-cli/cmd/parse.go
Normal file
71
insertr-cli/cmd/parse.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/insertr/cli/pkg/parser"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var parseCmd = &cobra.Command{
|
||||||
|
Use: "parse [input-dir]",
|
||||||
|
Short: "Parse HTML files and detect editable elements",
|
||||||
|
Long: `Parse HTML files in the specified directory and detect elements
|
||||||
|
with the 'insertr' class. This command analyzes the HTML structure
|
||||||
|
and reports what editable elements would be enhanced.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
inputDir := args[0]
|
||||||
|
|
||||||
|
if _, err := os.Stat(inputDir); os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Directory %s does not exist\n", inputDir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("🔍 Parsing HTML files in: %s\n\n", inputDir)
|
||||||
|
|
||||||
|
p := parser.New()
|
||||||
|
result, err := p.ParseDirectory(inputDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
printParseResults(result)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func printParseResults(result *parser.ParseResult) {
|
||||||
|
fmt.Printf("📊 Parse Results:\n")
|
||||||
|
fmt.Printf(" Files processed: %d\n", result.Stats.FilesProcessed)
|
||||||
|
fmt.Printf(" Elements found: %d\n", result.Stats.TotalElements)
|
||||||
|
fmt.Printf(" Existing IDs: %d\n", result.Stats.ExistingIDs)
|
||||||
|
fmt.Printf(" Generated IDs: %d\n", result.Stats.GeneratedIDs)
|
||||||
|
|
||||||
|
if len(result.Stats.TypeBreakdown) > 0 {
|
||||||
|
fmt.Printf("\n📝 Content Types:\n")
|
||||||
|
for contentType, count := range result.Stats.TypeBreakdown {
|
||||||
|
fmt.Printf(" %s: %d\n", contentType, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Elements) > 0 {
|
||||||
|
fmt.Printf("\n🎯 Found Elements:\n")
|
||||||
|
for _, element := range result.Elements {
|
||||||
|
fmt.Printf(" %s <%s> id=%s type=%s\n",
|
||||||
|
filepath.Base(element.FilePath),
|
||||||
|
element.Tag,
|
||||||
|
element.ContentID,
|
||||||
|
element.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Warnings) > 0 {
|
||||||
|
fmt.Printf("\n⚠️ Warnings:\n")
|
||||||
|
for _, warning := range result.Warnings {
|
||||||
|
fmt.Printf(" %s\n", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
insertr-cli/cmd/root.go
Normal file
31
insertr-cli/cmd/root.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "insertr",
|
||||||
|
Short: "Insertr CLI - HTML enhancement for static sites",
|
||||||
|
Long: `Insertr CLI adds editing capabilities to static HTML sites by detecting
|
||||||
|
editable elements and injecting content management functionality.
|
||||||
|
|
||||||
|
The tool parses HTML files, finds elements with the 'insertr' class,
|
||||||
|
and enhances them with editing capabilities while preserving
|
||||||
|
static site performance.`,
|
||||||
|
Version: "0.0.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(parseCmd)
|
||||||
|
}
|
||||||
87
insertr-cli/cmd/servedev.go
Normal file
87
insertr-cli/cmd/servedev.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var servedevCmd = &cobra.Command{
|
||||||
|
Use: "servedev",
|
||||||
|
Short: "Development server that parses and serves enhanced HTML files",
|
||||||
|
Long: `Servedev starts a development HTTP server that automatically parses HTML files
|
||||||
|
for insertr elements and serves the enhanced content. Perfect for development workflow
|
||||||
|
with live rebuilds via Air.`,
|
||||||
|
Run: runServedev,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
inputDir string
|
||||||
|
port int
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(servedevCmd)
|
||||||
|
|
||||||
|
servedevCmd.Flags().StringVarP(&inputDir, "input", "i", ".", "Input directory to serve")
|
||||||
|
servedevCmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to serve on")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServedev(cmd *cobra.Command, args []string) {
|
||||||
|
// Resolve absolute path for input directory
|
||||||
|
absInputDir, err := filepath.Abs(inputDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error resolving input directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if input directory exists
|
||||||
|
if _, err := os.Stat(absInputDir); os.IsNotExist(err) {
|
||||||
|
log.Fatalf("Input directory does not exist: %s", absInputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("🚀 Starting development server...\n")
|
||||||
|
fmt.Printf("📁 Serving directory: %s\n", absInputDir)
|
||||||
|
fmt.Printf("🌐 Server running at: http://localhost:%d\n", port)
|
||||||
|
fmt.Printf("🔄 Manually refresh browser to see changes\n\n")
|
||||||
|
|
||||||
|
// Create file server
|
||||||
|
fileServer := http.FileServer(&enhancedFileSystem{
|
||||||
|
fs: http.Dir(absInputDir),
|
||||||
|
dir: absInputDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle all requests with our enhanced file server
|
||||||
|
http.Handle("/", fileServer)
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// enhancedFileSystem wraps http.FileSystem to provide enhanced HTML serving
|
||||||
|
type enhancedFileSystem struct {
|
||||||
|
fs http.FileSystem
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (efs *enhancedFileSystem) Open(name string) (http.File, error) {
|
||||||
|
file, err := efs.fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML files, we'll eventually enhance them here
|
||||||
|
// For now, just serve them as-is
|
||||||
|
if strings.HasSuffix(name, ".html") {
|
||||||
|
fmt.Printf("📄 Serving HTML: %s\n", name)
|
||||||
|
fmt.Println("🔍 Parser ran!")
|
||||||
|
// TODO: Parse for insertr elements and enhance
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
15
insertr-cli/go.mod
Normal file
15
insertr-cli/go.mod
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module github.com/insertr/cli
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.24.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
golang.org/x/net v0.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
12
insertr-cli/go.sum
Normal file
12
insertr-cli/go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
9
insertr-cli/main.go
Normal file
9
insertr-cli/main.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/insertr/cli/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
177
insertr-cli/pkg/parser/id_generator.go
Normal file
177
insertr-cli/pkg/parser/id_generator.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDGenerator generates unique content IDs for elements
|
||||||
|
type IDGenerator struct {
|
||||||
|
usedIDs map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIDGenerator creates a new ID generator
|
||||||
|
func NewIDGenerator() *IDGenerator {
|
||||||
|
return &IDGenerator{
|
||||||
|
usedIDs: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate creates a content ID for an HTML element
|
||||||
|
func (g *IDGenerator) Generate(node *html.Node) string {
|
||||||
|
context := g.getSemanticContext(node)
|
||||||
|
purpose := g.getPurpose(node)
|
||||||
|
content := g.getContentSample(node)
|
||||||
|
|
||||||
|
baseID := g.createBaseID(context, purpose, content)
|
||||||
|
return g.ensureUnique(baseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSemanticContext determines the semantic context from parent elements
|
||||||
|
func (g *IDGenerator) getSemanticContext(node *html.Node) string {
|
||||||
|
// Walk up the tree to find semantic containers
|
||||||
|
parent := node.Parent
|
||||||
|
for parent != nil && parent.Type == html.ElementNode {
|
||||||
|
classes := getClasses(parent)
|
||||||
|
|
||||||
|
// Check for common semantic section classes
|
||||||
|
for _, class := range []string{"hero", "services", "nav", "navbar", "footer", "about", "contact", "testimonial"} {
|
||||||
|
if containsClass(classes, class) {
|
||||||
|
return class
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for semantic HTML elements
|
||||||
|
switch parent.Data {
|
||||||
|
case "nav":
|
||||||
|
return "nav"
|
||||||
|
case "header":
|
||||||
|
return "header"
|
||||||
|
case "footer":
|
||||||
|
return "footer"
|
||||||
|
case "main":
|
||||||
|
return "main"
|
||||||
|
case "aside":
|
||||||
|
return "aside"
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = parent.Parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return "content"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPurpose determines the purpose/role of the element
|
||||||
|
func (g *IDGenerator) getPurpose(node *html.Node) string {
|
||||||
|
tag := strings.ToLower(node.Data)
|
||||||
|
classes := getClasses(node)
|
||||||
|
|
||||||
|
// Check for specific CSS classes that indicate purpose
|
||||||
|
for _, class := range classes {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(class, "title"):
|
||||||
|
return "title"
|
||||||
|
case strings.Contains(class, "headline"):
|
||||||
|
return "headline"
|
||||||
|
case strings.Contains(class, "description"):
|
||||||
|
return "description"
|
||||||
|
case strings.Contains(class, "subtitle"):
|
||||||
|
return "subtitle"
|
||||||
|
case strings.Contains(class, "cta"):
|
||||||
|
return "cta"
|
||||||
|
case strings.Contains(class, "button"):
|
||||||
|
return "button"
|
||||||
|
case strings.Contains(class, "logo"):
|
||||||
|
return "logo"
|
||||||
|
case strings.Contains(class, "lead"):
|
||||||
|
return "lead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer purpose from HTML tag
|
||||||
|
switch tag {
|
||||||
|
case "h1":
|
||||||
|
return "title"
|
||||||
|
case "h2":
|
||||||
|
return "subtitle"
|
||||||
|
case "h3", "h4", "h5", "h6":
|
||||||
|
return "heading"
|
||||||
|
case "p":
|
||||||
|
return "text"
|
||||||
|
case "a":
|
||||||
|
return "link"
|
||||||
|
case "button":
|
||||||
|
return "button"
|
||||||
|
default:
|
||||||
|
return "content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContentSample gets a sample of content for ID generation
|
||||||
|
func (g *IDGenerator) getContentSample(node *html.Node) string {
|
||||||
|
text := extractTextContent(node)
|
||||||
|
|
||||||
|
// Clean and normalize text
|
||||||
|
text = strings.ToLower(text)
|
||||||
|
text = regexp.MustCompile(`[^a-z0-9\s]+`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
|
||||||
|
// Take first few words
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) > 3 {
|
||||||
|
words = words[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(words, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBaseID creates the base ID from components
|
||||||
|
func (g *IDGenerator) createBaseID(context, purpose, content string) string {
|
||||||
|
parts := []string{}
|
||||||
|
|
||||||
|
// Add context if meaningful
|
||||||
|
if context != "content" {
|
||||||
|
parts = append(parts, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add purpose
|
||||||
|
parts = append(parts, purpose)
|
||||||
|
|
||||||
|
// Add content sample if available and meaningful
|
||||||
|
if content != "" && content != purpose {
|
||||||
|
parts = append(parts, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseID := strings.Join(parts, "-")
|
||||||
|
|
||||||
|
// Clean up the ID
|
||||||
|
baseID = regexp.MustCompile(`-+`).ReplaceAllString(baseID, "-")
|
||||||
|
baseID = strings.Trim(baseID, "-")
|
||||||
|
|
||||||
|
// Ensure it's not empty
|
||||||
|
if baseID == "" {
|
||||||
|
baseID = "content"
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureUnique makes sure the ID is unique by adding a suffix if needed
|
||||||
|
func (g *IDGenerator) ensureUnique(baseID string) string {
|
||||||
|
if !g.usedIDs[baseID] {
|
||||||
|
g.usedIDs[baseID] = true
|
||||||
|
return baseID
|
||||||
|
}
|
||||||
|
|
||||||
|
// If base ID is taken, add a hash suffix
|
||||||
|
hash := fmt.Sprintf("%x", sha1.Sum([]byte(baseID)))[:6]
|
||||||
|
uniqueID := fmt.Sprintf("%s-%s", baseID, hash)
|
||||||
|
|
||||||
|
g.usedIDs[uniqueID] = true
|
||||||
|
return uniqueID
|
||||||
|
}
|
||||||
229
insertr-cli/pkg/parser/parser.go
Normal file
229
insertr-cli/pkg/parser/parser.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parser handles HTML parsing and element detection
|
||||||
|
type Parser struct {
|
||||||
|
idGenerator *IDGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Parser instance
|
||||||
|
func New() *Parser {
|
||||||
|
return &Parser{
|
||||||
|
idGenerator: NewIDGenerator(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDirectory parses all HTML files in the given directory
|
||||||
|
func (p *Parser) ParseDirectory(dir string) (*ParseResult, error) {
|
||||||
|
result := &ParseResult{
|
||||||
|
Elements: []Element{},
|
||||||
|
Warnings: []string{},
|
||||||
|
Stats: ParseStats{
|
||||||
|
TypeBreakdown: make(map[ContentType]int),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process HTML files
|
||||||
|
if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".html") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elements, warnings, err := p.parseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
result.Warnings = append(result.Warnings,
|
||||||
|
fmt.Sprintf("Error parsing %s: %v", path, err))
|
||||||
|
return nil // Continue processing other files
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Elements = append(result.Elements, elements...)
|
||||||
|
result.Warnings = append(result.Warnings, warnings...)
|
||||||
|
result.Stats.FilesProcessed++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error walking directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
p.calculateStats(result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFile parses a single HTML file
|
||||||
|
func (p *Parser) parseFile(filePath string) ([]Element, []string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("error opening file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
doc, err := html.Parse(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("error parsing HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var elements []Element
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
p.findInsertrElements(doc, filePath, &elements, &warnings)
|
||||||
|
|
||||||
|
return elements, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findInsertrElements recursively finds all elements with "insertr" class
|
||||||
|
func (p *Parser) findInsertrElements(node *html.Node, filePath string, elements *[]Element, warnings *[]string) {
|
||||||
|
if node.Type == html.ElementNode {
|
||||||
|
classes := getClasses(node)
|
||||||
|
|
||||||
|
// Check if element has "insertr" class
|
||||||
|
if containsClass(classes, "insertr") {
|
||||||
|
if isContainer(node) {
|
||||||
|
// Container element - expand to viable children
|
||||||
|
viableChildren := findViableChildren(node)
|
||||||
|
for _, child := range viableChildren {
|
||||||
|
childClasses := getClasses(child)
|
||||||
|
element, warning := p.createElement(child, filePath, childClasses)
|
||||||
|
*elements = append(*elements, element)
|
||||||
|
if warning != "" {
|
||||||
|
*warnings = append(*warnings, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't process children recursively since we've handled the container's children
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Regular element - process as before
|
||||||
|
element, warning := p.createElement(node, filePath, classes)
|
||||||
|
*elements = append(*elements, element)
|
||||||
|
if warning != "" {
|
||||||
|
*warnings = append(*warnings, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check children
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
p.findInsertrElements(child, filePath, elements, warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createElement creates an Element from an HTML node
|
||||||
|
func (p *Parser) createElement(node *html.Node, filePath string, classes []string) (Element, string) {
|
||||||
|
var warning string
|
||||||
|
|
||||||
|
// Resolve content ID (existing or generated)
|
||||||
|
contentID, hasExistingID := p.resolveContentID(node)
|
||||||
|
if !hasExistingID {
|
||||||
|
contentID = p.idGenerator.Generate(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect content type
|
||||||
|
contentType := p.detectContentType(node, classes)
|
||||||
|
|
||||||
|
// Extract text content
|
||||||
|
content := extractTextContent(node)
|
||||||
|
|
||||||
|
element := Element{
|
||||||
|
FilePath: filePath,
|
||||||
|
Node: node,
|
||||||
|
ContentID: contentID,
|
||||||
|
Type: contentType,
|
||||||
|
Tag: strings.ToLower(node.Data),
|
||||||
|
Classes: classes,
|
||||||
|
Content: content,
|
||||||
|
HasID: hasExistingID,
|
||||||
|
Generated: !hasExistingID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate warnings for edge cases
|
||||||
|
if content == "" {
|
||||||
|
warning = fmt.Sprintf("Element <%s> with id '%s' has no text content",
|
||||||
|
element.Tag, element.ContentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return element, warning
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveContentID gets the content ID from existing attributes
|
||||||
|
func (p *Parser) resolveContentID(node *html.Node) (string, bool) {
|
||||||
|
// 1. Check for existing HTML id attribute
|
||||||
|
if id := getAttribute(node, "id"); id != "" {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for data-content-id attribute
|
||||||
|
if contentID := getAttribute(node, "data-content-id"); contentID != "" {
|
||||||
|
return contentID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No existing ID found
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectContentType determines the content type based on element and classes
|
||||||
|
func (p *Parser) detectContentType(node *html.Node, classes []string) ContentType {
|
||||||
|
// Check for explicit type classes first
|
||||||
|
if containsClass(classes, "insertr-markdown") {
|
||||||
|
return ContentMarkdown
|
||||||
|
}
|
||||||
|
if containsClass(classes, "insertr-link") {
|
||||||
|
return ContentLink
|
||||||
|
}
|
||||||
|
if containsClass(classes, "insertr-text") {
|
||||||
|
return ContentText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer from HTML tag and context
|
||||||
|
tag := strings.ToLower(node.Data)
|
||||||
|
switch tag {
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
return ContentText
|
||||||
|
case "p":
|
||||||
|
// Paragraphs default to markdown for rich content
|
||||||
|
return ContentMarkdown
|
||||||
|
case "a", "button":
|
||||||
|
return ContentLink
|
||||||
|
case "div", "section":
|
||||||
|
// Default divs/sections to markdown for rich content
|
||||||
|
return ContentMarkdown
|
||||||
|
case "span":
|
||||||
|
return ContentText
|
||||||
|
default:
|
||||||
|
return ContentText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateStats computes statistics for the parse result
|
||||||
|
func (p *Parser) calculateStats(result *ParseResult) {
|
||||||
|
result.Stats.TotalElements = len(result.Elements)
|
||||||
|
|
||||||
|
for _, element := range result.Elements {
|
||||||
|
// Count existing vs generated IDs
|
||||||
|
if element.HasID {
|
||||||
|
result.Stats.ExistingIDs++
|
||||||
|
} else {
|
||||||
|
result.Stats.GeneratedIDs++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count content types
|
||||||
|
result.Stats.TypeBreakdown[element.Type]++
|
||||||
|
}
|
||||||
|
}
|
||||||
41
insertr-cli/pkg/parser/types.go
Normal file
41
insertr-cli/pkg/parser/types.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import "golang.org/x/net/html"
|
||||||
|
|
||||||
|
// ContentType represents the type of editable content
|
||||||
|
type ContentType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContentText ContentType = "text"
|
||||||
|
ContentMarkdown ContentType = "markdown"
|
||||||
|
ContentLink ContentType = "link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Element represents a parsed editable element
|
||||||
|
type Element struct {
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Node *html.Node `json:"-"` // Don't serialize HTML node
|
||||||
|
ContentID string `json:"content_id"`
|
||||||
|
Type ContentType `json:"type"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Classes []string `json:"classes"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
HasID bool `json:"has_id"` // Whether element had existing ID
|
||||||
|
Generated bool `json:"generated"` // Whether ID was generated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseResult contains the results of parsing HTML files
|
||||||
|
type ParseResult struct {
|
||||||
|
Elements []Element `json:"elements"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Stats ParseStats `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStats provides statistics about the parsing operation
|
||||||
|
type ParseStats struct {
|
||||||
|
FilesProcessed int `json:"files_processed"`
|
||||||
|
TotalElements int `json:"total_elements"`
|
||||||
|
ExistingIDs int `json:"existing_ids"`
|
||||||
|
GeneratedIDs int `json:"generated_ids"`
|
||||||
|
TypeBreakdown map[ContentType]int `json:"type_breakdown"`
|
||||||
|
}
|
||||||
159
insertr-cli/pkg/parser/utils.go
Normal file
159
insertr-cli/pkg/parser/utils.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getClasses extracts CSS classes from an HTML node
|
||||||
|
func getClasses(node *html.Node) []string {
|
||||||
|
classAttr := getAttribute(node, "class")
|
||||||
|
if classAttr == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
classes := strings.Fields(classAttr)
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsClass checks if a class list contains a specific class
|
||||||
|
func containsClass(classes []string, target string) bool {
|
||||||
|
for _, class := range classes {
|
||||||
|
if class == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAttribute gets an attribute value from an HTML node
|
||||||
|
func getAttribute(node *html.Node, key string) string {
|
||||||
|
for _, attr := range node.Attr {
|
||||||
|
if attr.Key == key {
|
||||||
|
return attr.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTextContent gets the text content from an HTML node
|
||||||
|
func extractTextContent(node *html.Node) string {
|
||||||
|
var text strings.Builder
|
||||||
|
extractTextRecursive(node, &text)
|
||||||
|
return strings.TrimSpace(text.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTextRecursive recursively extracts text from node and children
|
||||||
|
func extractTextRecursive(node *html.Node, text *strings.Builder) {
|
||||||
|
if node.Type == html.TextNode {
|
||||||
|
text.WriteString(node.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
// Skip script and style elements
|
||||||
|
if child.Type == html.ElementNode &&
|
||||||
|
(child.Data == "script" || child.Data == "style") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extractTextRecursive(child, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasOnlyTextContent checks if a node contains only text content (no nested HTML elements)
|
||||||
|
func hasOnlyTextContent(node *html.Node) bool {
|
||||||
|
if node.Type != html.ElementNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
switch child.Type {
|
||||||
|
case html.ElementNode:
|
||||||
|
// Found a nested HTML element - not text-only
|
||||||
|
return false
|
||||||
|
case html.TextNode:
|
||||||
|
// Text nodes are fine, continue checking
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
// Comments, etc. - continue checking
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isContainer checks if a tag is typically used as a container element
|
||||||
|
func isContainer(node *html.Node) bool {
|
||||||
|
if node.Type != html.ElementNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
containerTags := map[string]bool{
|
||||||
|
"div": true,
|
||||||
|
"section": true,
|
||||||
|
"article": true,
|
||||||
|
"header": true,
|
||||||
|
"footer": true,
|
||||||
|
"main": true,
|
||||||
|
"aside": true,
|
||||||
|
"nav": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerTags[node.Data]
|
||||||
|
}
|
||||||
|
|
||||||
|
// findViableChildren finds all child elements that are viable for editing
|
||||||
|
func findViableChildren(node *html.Node) []*html.Node {
|
||||||
|
var viable []*html.Node
|
||||||
|
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
// Skip whitespace-only text nodes
|
||||||
|
if child.Type == html.TextNode {
|
||||||
|
if strings.TrimSpace(child.Data) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only consider element nodes
|
||||||
|
if child.Type != html.ElementNode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip self-closing elements for now
|
||||||
|
if isSelfClosing(child) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element has only text content
|
||||||
|
if hasOnlyTextContent(child) {
|
||||||
|
viable = append(viable, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return viable
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSelfClosing checks if an element is typically self-closing
|
||||||
|
func isSelfClosing(node *html.Node) bool {
|
||||||
|
if node.Type != html.ElementNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
selfClosingTags := map[string]bool{
|
||||||
|
"img": true,
|
||||||
|
"input": true,
|
||||||
|
"br": true,
|
||||||
|
"hr": true,
|
||||||
|
"meta": true,
|
||||||
|
"link": true,
|
||||||
|
"area": true,
|
||||||
|
"base": true,
|
||||||
|
"col": true,
|
||||||
|
"embed": true,
|
||||||
|
"source": true,
|
||||||
|
"track": true,
|
||||||
|
"wbr": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return selfClosingTags[node.Data]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user