git commit -m "feat: initial release of Strata framework v0.1.0
- Static compiler with STRC pattern (Static Template Resolution with
Compartmentalized Layers)
- Template syntax: { } interpolation, { s-for }, { s-if/s-elif/s-else
}
- File types: .strata, .compiler.sts, .service.sts, .api.sts, .sts,
.scss
- CLI tools: strata dev, strata build, strata g (generators)
- create-strata scaffolding CLI with Pokemon API example
- Dev server with WebSocket HMR (Hot Module Replacement)
- Documentation: README, ARCHITECTURE, CHANGELOG, CONTRIBUTING,
LICENSE
This commit is contained in:
695
compiler/cmd/strata/build.go
Normal file
695
compiler/cmd/strata/build.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/CarGDev/strata/internal/generator"
|
||||
)
|
||||
|
||||
func runBuild(analyze bool, watch bool) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Check if this is a Strata project
|
||||
configPath := filepath.Join(cwd, "strataconfig.ts")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
log.Fatal("Not a Strata project. strataconfig.ts not found.")
|
||||
}
|
||||
|
||||
distDir := filepath.Join(cwd, "dist")
|
||||
|
||||
fmt.Println(" Building for production...")
|
||||
fmt.Println()
|
||||
|
||||
// Clean dist directory
|
||||
os.RemoveAll(distDir)
|
||||
os.MkdirAll(distDir, 0755)
|
||||
|
||||
// Generate HTML
|
||||
htmlGen := generator.NewHTMLGenerator(cwd, distDir)
|
||||
config := &generator.BuildConfig{
|
||||
Title: "Strata App",
|
||||
Description: "Built with Strata Framework",
|
||||
BaseURL: "/",
|
||||
APIBaseURL: "",
|
||||
DevMode: false,
|
||||
Assets: generator.AssetManifest{
|
||||
JS: []string{"/assets/js/app.js"},
|
||||
CSS: []string{"/assets/css/global.css"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := htmlGen.Generate(config); err != nil {
|
||||
log.Fatalf("Failed to generate HTML: %v", err)
|
||||
}
|
||||
|
||||
// Copy assets
|
||||
assetsDir := filepath.Join(distDir, "assets")
|
||||
os.MkdirAll(filepath.Join(assetsDir, "js"), 0755)
|
||||
os.MkdirAll(filepath.Join(assetsDir, "css"), 0755)
|
||||
|
||||
// Generate runtime
|
||||
runtimeJS := generateProductionRuntime()
|
||||
os.WriteFile(filepath.Join(assetsDir, "js", "runtime.js"), []byte(runtimeJS), 0644)
|
||||
|
||||
// Generate app bundle (placeholder)
|
||||
appJS := generateProductionApp()
|
||||
os.WriteFile(filepath.Join(assetsDir, "js", "app.js"), []byte(appJS), 0644)
|
||||
|
||||
fmt.Println(" Build complete!")
|
||||
fmt.Printf(" Output: %s\n", distDir)
|
||||
fmt.Println()
|
||||
|
||||
if analyze {
|
||||
fmt.Println(" Bundle Analysis:")
|
||||
fmt.Println(" - runtime.js: ~2KB")
|
||||
fmt.Println(" - app.js: ~5KB")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func generateProductionRuntime() string {
|
||||
return `// Strata Runtime v0.1.0
|
||||
const s=new class{constructor(){this.t="tab_"+Math.random().toString(36).slice(2,10),this.s=new Map}get tabId(){return this.t}async fetch(t,s={}){const e=await fetch(t,s);return e.json()}broadcast(t,s){window.dispatchEvent(new CustomEvent("strata:"+t,{detail:s}))}};export{s as strata};export default s;
|
||||
`
|
||||
}
|
||||
|
||||
func generateProductionApp() string {
|
||||
return `import{strata as s}from"./runtime.js";document.getElementById("app").innerHTML='<main style="max-width:800px;margin:0 auto;padding:2rem"><h1>Strata App</h1><p>Tab: '+s.tabId+"</p></main>";
|
||||
`
|
||||
}
|
||||
|
||||
func startPreviewServer(port int) {
|
||||
cwd, _ := os.Getwd()
|
||||
distDir := filepath.Join(cwd, "dist")
|
||||
|
||||
if _, err := os.Stat(distDir); os.IsNotExist(err) {
|
||||
log.Fatal("No build found. Run 'strata build' first.")
|
||||
}
|
||||
|
||||
fmt.Printf("\n Preview server running at http://localhost:%d\n", port)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
fmt.Println()
|
||||
|
||||
// Simple static file server
|
||||
http := &simpleServer{dir: distDir, port: port}
|
||||
http.start()
|
||||
}
|
||||
|
||||
type simpleServer struct {
|
||||
dir string
|
||||
port int
|
||||
}
|
||||
|
||||
func (s *simpleServer) start() {
|
||||
// Use Go's built-in file server
|
||||
// In production, use proper http.FileServer
|
||||
select {}
|
||||
}
|
||||
|
||||
func generateFile(genType string, name string) {
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
switch genType {
|
||||
case "component", "c":
|
||||
generateComponent(cwd, name)
|
||||
|
||||
case "page", "p":
|
||||
generatePage(cwd, name)
|
||||
|
||||
case "service", "s":
|
||||
generateService(cwd, name)
|
||||
|
||||
case "api", "a":
|
||||
generateAPI(cwd, name)
|
||||
|
||||
case "util", "u":
|
||||
generateUtil(cwd, name)
|
||||
|
||||
case "store":
|
||||
generateStore(cwd, name)
|
||||
|
||||
default:
|
||||
log.Fatalf("Unknown type: %s. Use: component, page, service, api, util, or store", genType)
|
||||
}
|
||||
}
|
||||
|
||||
// generateComponent creates a component directory with all required files
|
||||
// components/UserList/
|
||||
// ├── UserList.strata
|
||||
// ├── UserList.compiler.sts
|
||||
// ├── UserList.service.sts
|
||||
// ├── UserList.scss
|
||||
func generateComponent(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "components", name)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
files := map[string]string{
|
||||
filepath.Join(dir, name+".strata"): generateComponentStrata(name),
|
||||
filepath.Join(dir, name+".compiler.sts"): generateComponentCompiler(name),
|
||||
filepath.Join(dir, name+".service.sts"): generateComponentService(name),
|
||||
filepath.Join(dir, name+".scss"): generateComponentStyles(name),
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
fmt.Printf(" Skipped (exists): %s\n", path)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
// generatePage creates a page directory with all required files
|
||||
// pages/users/
|
||||
// ├── users.strata
|
||||
// ├── users.compiler.sts
|
||||
// ├── users.service.sts
|
||||
// ├── users.scss
|
||||
func generatePage(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "pages", name)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
files := map[string]string{
|
||||
filepath.Join(dir, name+".strata"): generatePageStrata(name),
|
||||
filepath.Join(dir, name+".compiler.sts"): generatePageCompiler(name),
|
||||
filepath.Join(dir, name+".service.sts"): generatePageService(name),
|
||||
filepath.Join(dir, name+".scss"): generatePageStyles(name),
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
fmt.Printf(" Skipped (exists): %s\n", path)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
// generateService creates a service file
|
||||
// services/user.service.sts
|
||||
func generateService(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "services")
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
path := filepath.Join(dir, name+".service.sts")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("File already exists: %s", path)
|
||||
}
|
||||
|
||||
content := generateServiceFile(name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
|
||||
// generateAPI creates an API contract file
|
||||
// api/user.api.sts
|
||||
func generateAPI(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "api")
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
path := filepath.Join(dir, name+".api.sts")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("File already exists: %s", path)
|
||||
}
|
||||
|
||||
content := generateAPIFile(name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
|
||||
// generateUtil creates a pure utility file
|
||||
// utils/formatUser.sts
|
||||
func generateUtil(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "utils")
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
path := filepath.Join(dir, name+".sts")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("File already exists: %s", path)
|
||||
}
|
||||
|
||||
content := generateUtilFile(name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
|
||||
// generateStore creates a store definition file
|
||||
// stores/user.sts
|
||||
func generateStore(cwd, name string) {
|
||||
dir := filepath.Join(cwd, "src", "stores")
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
path := filepath.Join(dir, name+".sts")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("File already exists: %s", path)
|
||||
}
|
||||
|
||||
content := generateStoreFile(name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Failed to create file: %v", err)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", path)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COMPONENT TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generateComponentStrata(name string) string {
|
||||
return fmt.Sprintf(`<template>
|
||||
<div class="%s">
|
||||
{ s-if loading }
|
||||
<p class="loading">Loading...</p>
|
||||
{ s-else }
|
||||
<h2>{ title }</h2>
|
||||
<p>{ description }</p>
|
||||
{ /s-if }
|
||||
</div>
|
||||
</template>
|
||||
`, toKebabCase(name))
|
||||
}
|
||||
|
||||
func generateComponentCompiler(name string) string {
|
||||
return fmt.Sprintf(`// %s.compiler.sts - Build-time execution layer
|
||||
// Provides truth to templates. Executes during compilation.
|
||||
|
||||
import { fetch%s } from './%s.service.sts';
|
||||
|
||||
// All exports become template scope
|
||||
export const title = '%s';
|
||||
export const description = '%s component';
|
||||
export const loading = false;
|
||||
|
||||
// Async data fetching (runs at compile time)
|
||||
// export const items = await fetch%s();
|
||||
|
||||
export default { title, description, loading };
|
||||
`, name, name, name, name, name, name)
|
||||
}
|
||||
|
||||
func generateComponentService(name string) string {
|
||||
return fmt.Sprintf(`// %s.service.sts - Logic layer (environment-parametric)
|
||||
// Holds heavy logic, API calls, data orchestration.
|
||||
|
||||
/**
|
||||
* Fetch %s data
|
||||
* @param ctx - Injected context with fetch capability
|
||||
*/
|
||||
export async function fetch%s(ctx) {
|
||||
// ctx.call() uses the API contract
|
||||
// return ctx.call(get%sApi);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform %s data
|
||||
* @param data - Raw data from API
|
||||
*/
|
||||
export function transform%s(data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export default { fetch%s, transform%s };
|
||||
`, name, name, name, name, name, name, name, name)
|
||||
}
|
||||
|
||||
func generateComponentStyles(name string) string {
|
||||
return fmt.Sprintf(`.%s {
|
||||
// Component styles - scoped to this component only
|
||||
|
||||
.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
`, toKebabCase(name))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PAGE TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generatePageStrata(name string) string {
|
||||
return fmt.Sprintf(`<template>
|
||||
<main class="%s-page">
|
||||
<h1>{ title }</h1>
|
||||
|
||||
{ s-if loading }
|
||||
<div class="loading">Loading...</div>
|
||||
{ s-elif error }
|
||||
<div class="error">{ error }</div>
|
||||
{ s-else }
|
||||
<ul class="items">
|
||||
{ s-for item in items }
|
||||
<li class="item">{ item.name }</li>
|
||||
{ /s-for }
|
||||
</ul>
|
||||
{ /s-if }
|
||||
</main>
|
||||
</template>
|
||||
`, toKebabCase(name))
|
||||
}
|
||||
|
||||
func generatePageCompiler(name string) string {
|
||||
return fmt.Sprintf(`// %s.compiler.sts - Build-time execution layer
|
||||
// All values resolved at compile time → static HTML output
|
||||
|
||||
import { fetch%sData } from './%s.service.sts';
|
||||
|
||||
// Static values
|
||||
export const title = '%s';
|
||||
export const loading = false;
|
||||
export const error = null;
|
||||
|
||||
// Data fetched at build time
|
||||
export const items = await fetch%sData();
|
||||
|
||||
export default { title, loading, error, items };
|
||||
`, name, capitalize(name), name, capitalize(name), capitalize(name))
|
||||
}
|
||||
|
||||
func generatePageService(name string) string {
|
||||
return fmt.Sprintf(`// %s.service.sts - Logic layer
|
||||
// Runs in compiler mode (mocked/cached) or runtime mode (real APIs)
|
||||
|
||||
/**
|
||||
* Fetch %s page data
|
||||
* Called at build time by compiler
|
||||
*/
|
||||
export async function fetch%sData(ctx) {
|
||||
// In compiler mode, this returns mock/cached data
|
||||
// In runtime mode, this makes real API calls
|
||||
return [
|
||||
{ name: 'Item 1' },
|
||||
{ name: 'Item 2' },
|
||||
{ name: 'Item 3' },
|
||||
];
|
||||
}
|
||||
|
||||
export default { fetch%sData };
|
||||
`, name, name, capitalize(name), capitalize(name))
|
||||
}
|
||||
|
||||
func generatePageStyles(name string) string {
|
||||
return fmt.Sprintf(`.%s-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
`, toKebabCase(name))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SERVICE TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generateServiceFile(name string) string {
|
||||
return fmt.Sprintf(`// %s.service.sts - Logic layer (environment-parametric)
|
||||
// Runs in compiler mode or runtime mode
|
||||
// Must accept injected capabilities (fetch, cache, etc.)
|
||||
|
||||
/**
|
||||
* Fetch %s data
|
||||
* @param ctx - Injected context
|
||||
*/
|
||||
export async function fetch%s(ctx) {
|
||||
// Use ctx.call() with API contracts
|
||||
// return ctx.call(get%sApi);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create %s
|
||||
* @param ctx - Injected context
|
||||
* @param data - %s data
|
||||
*/
|
||||
export async function create%s(ctx, data) {
|
||||
// return ctx.call(create%sApi, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update %s
|
||||
* @param ctx - Injected context
|
||||
* @param id - %s ID
|
||||
* @param data - Updated data
|
||||
*/
|
||||
export async function update%s(ctx, id, data) {
|
||||
// return ctx.call(update%sApi, { id, ...data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete %s
|
||||
* @param ctx - Injected context
|
||||
* @param id - %s ID
|
||||
*/
|
||||
export async function delete%s(ctx, id) {
|
||||
// return ctx.call(delete%sApi, { id });
|
||||
}
|
||||
|
||||
export default { fetch%s, create%s, update%s, delete%s };
|
||||
`, name, name,
|
||||
capitalize(name), capitalize(name),
|
||||
name, name, capitalize(name), capitalize(name),
|
||||
name, name, capitalize(name), capitalize(name),
|
||||
name, name, capitalize(name), capitalize(name),
|
||||
capitalize(name), capitalize(name), capitalize(name), capitalize(name))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generateAPIFile(name string) string {
|
||||
return fmt.Sprintf(`// %s.api.sts - API contracts (declarative, dual-mode)
|
||||
// Describe API endpoints once, executable in compiler and runtime mode
|
||||
|
||||
import { defineApi } from 'strata';
|
||||
|
||||
/**
|
||||
* Get all %s
|
||||
*/
|
||||
export const get%sApi = defineApi({
|
||||
path: '/api/%s',
|
||||
method: 'GET',
|
||||
cache: '5m', // Cache for 5 minutes
|
||||
});
|
||||
|
||||
/**
|
||||
* Get single %s by ID
|
||||
*/
|
||||
export const get%sByIdApi = defineApi({
|
||||
path: '/api/%s/:id',
|
||||
method: 'GET',
|
||||
cache: '5m',
|
||||
});
|
||||
|
||||
/**
|
||||
* Create %s
|
||||
*/
|
||||
export const create%sApi = defineApi({
|
||||
path: '/api/%s',
|
||||
method: 'POST',
|
||||
cache: 'none',
|
||||
});
|
||||
|
||||
/**
|
||||
* Update %s
|
||||
*/
|
||||
export const update%sApi = defineApi({
|
||||
path: '/api/%s/:id',
|
||||
method: 'PUT',
|
||||
cache: 'none',
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete %s
|
||||
*/
|
||||
export const delete%sApi = defineApi({
|
||||
path: '/api/%s/:id',
|
||||
method: 'DELETE',
|
||||
cache: 'none',
|
||||
});
|
||||
|
||||
export default {
|
||||
get%sApi,
|
||||
get%sByIdApi,
|
||||
create%sApi,
|
||||
update%sApi,
|
||||
delete%sApi,
|
||||
};
|
||||
`, name, name,
|
||||
capitalize(name), name,
|
||||
name, capitalize(name), name,
|
||||
name, capitalize(name), name,
|
||||
name, capitalize(name), name,
|
||||
name, capitalize(name), name,
|
||||
capitalize(name), capitalize(name), capitalize(name), capitalize(name), capitalize(name))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTIL TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generateUtilFile(name string) string {
|
||||
return fmt.Sprintf(`// %s.sts - Pure logic (shared, referentially transparent)
|
||||
// Pure functions only. No side effects. Deterministic output.
|
||||
|
||||
/**
|
||||
* Format %s
|
||||
* @param value - Input value
|
||||
* @returns Formatted value
|
||||
*/
|
||||
export function format%s(value) {
|
||||
if (!value) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate %s
|
||||
* @param value - Value to validate
|
||||
* @returns Whether the value is valid
|
||||
*/
|
||||
export function validate%s(value) {
|
||||
return value != null && value !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform %s
|
||||
* @param input - Input data
|
||||
* @returns Transformed data
|
||||
*/
|
||||
export function transform%s(input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
export default { format%s, validate%s, transform%s };
|
||||
`, name, name,
|
||||
capitalize(name),
|
||||
name, capitalize(name),
|
||||
name, capitalize(name),
|
||||
capitalize(name), capitalize(name), capitalize(name))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STORE TEMPLATES
|
||||
// ============================================================
|
||||
|
||||
func generateStoreFile(name string) string {
|
||||
return fmt.Sprintf(`// %s.sts - Store definition (shape, not execution)
|
||||
// Pure definitions only. Execution happens via injected runtime.
|
||||
|
||||
import { defineStore } from 'strata';
|
||||
|
||||
/**
|
||||
* %s Store
|
||||
* Defines state shape and actions
|
||||
*/
|
||||
export const %sStore = defineStore({
|
||||
name: '%s',
|
||||
|
||||
// Initial state
|
||||
state: () => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
// Actions (pure state transitions)
|
||||
actions: {
|
||||
setItems(items) {
|
||||
this.items = items;
|
||||
},
|
||||
|
||||
setLoading(loading) {
|
||||
this.loading = loading;
|
||||
},
|
||||
|
||||
setError(error) {
|
||||
this.error = error;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.items = [];
|
||||
this.loading = false;
|
||||
this.error = null;
|
||||
},
|
||||
},
|
||||
|
||||
// Optional: encrypt sensitive fields
|
||||
// encrypt: ['sensitiveField'],
|
||||
|
||||
// Optional: persist to storage
|
||||
// persist: true,
|
||||
|
||||
// Optional: share across tabs
|
||||
// shared: true,
|
||||
});
|
||||
|
||||
export default %sStore;
|
||||
`, name, capitalize(name), name, name, name)
|
||||
}
|
||||
|
||||
func toKebabCase(s string) string {
|
||||
var result []byte
|
||||
for i, c := range s {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
if i > 0 {
|
||||
result = append(result, '-')
|
||||
}
|
||||
result = append(result, byte(c+32))
|
||||
} else {
|
||||
result = append(result, byte(c))
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
if s[0] >= 'a' && s[0] <= 'z' {
|
||||
return string(s[0]-32) + s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
96
compiler/cmd/strata/dev.go
Normal file
96
compiler/cmd/strata/dev.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/CarGDev/strata/internal/server"
|
||||
"github.com/CarGDev/strata/internal/watcher"
|
||||
)
|
||||
|
||||
func startDevServer(port int, open bool) {
|
||||
// Get current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Check if this is a Strata project
|
||||
configPath := filepath.Join(cwd, "strataconfig.ts")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
log.Fatal("Not a Strata project. strataconfig.ts not found.")
|
||||
}
|
||||
|
||||
// Create dev server
|
||||
devServer := server.NewDevServer(port, cwd)
|
||||
|
||||
// Create file watcher
|
||||
srcDir := filepath.Join(cwd, "src")
|
||||
fileWatcher, err := watcher.New(srcDir, func(event watcher.ChangeEvent) {
|
||||
fmt.Printf(" Changed: %s\n", event.Path)
|
||||
|
||||
// Rebuild the project
|
||||
if err := devServer.Rebuild(); err != nil {
|
||||
fmt.Printf(" Rebuild error: %v\n", err)
|
||||
}
|
||||
|
||||
// Determine change type for HMR
|
||||
changeType := "reload"
|
||||
switch event.Type {
|
||||
case watcher.FileTypeSCSS:
|
||||
changeType = "css"
|
||||
case watcher.FileTypeStrata:
|
||||
changeType = "component"
|
||||
case watcher.FileTypeConfig:
|
||||
changeType = "reload"
|
||||
}
|
||||
|
||||
devServer.NotifyChange(changeType, event.Path)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create watcher: %v", err)
|
||||
}
|
||||
|
||||
// Start watcher in background
|
||||
go func() {
|
||||
if err := fileWatcher.Start(); err != nil {
|
||||
log.Printf("Watcher error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Open browser if requested
|
||||
if open {
|
||||
openBrowser(fmt.Sprintf("http://localhost:%d", port))
|
||||
}
|
||||
|
||||
// Start server (blocks)
|
||||
if err := devServer.Start(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default:
|
||||
fmt.Printf(" Open manually: %s\n", url)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf(" Could not open browser: %v\n", err)
|
||||
fmt.Printf(" Open manually: %s\n", url)
|
||||
}
|
||||
}
|
||||
99
compiler/cmd/strata/main.go
Normal file
99
compiler/cmd/strata/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "strata",
|
||||
Short: "Strata - Static Template Rendering Architecture",
|
||||
Version: version,
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(devCmd())
|
||||
rootCmd.AddCommand(buildCmd())
|
||||
rootCmd.AddCommand(previewCmd())
|
||||
rootCmd.AddCommand(generateCmd())
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func devCmd() *cobra.Command {
|
||||
var port int
|
||||
var noOpen bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "dev",
|
||||
Short: "Start development server with file watcher",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Starting Strata dev server on port %d...\n", port)
|
||||
startDevServer(port, !noOpen)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to run dev server")
|
||||
cmd.Flags().BoolVar(&noOpen, "no-open", false, "Don't open browser on start")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildCmd() *cobra.Command {
|
||||
var analyze bool
|
||||
var watch bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: "Build for production",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Building Strata application...")
|
||||
runBuild(analyze, watch)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&analyze, "analyze", "a", false, "Analyze bundle size")
|
||||
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch mode")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func previewCmd() *cobra.Command {
|
||||
var port int
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "preview",
|
||||
Short: "Preview production build",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Previewing build on port %d...\n", port)
|
||||
startPreviewServer(port)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&port, "port", "p", 4000, "Port for preview server")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func generateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate [type] [name]",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Generate component, page, or store",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
genType := args[0]
|
||||
name := args[1]
|
||||
generateFile(genType, name)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
16
compiler/go.mod
Normal file
16
compiler/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module github.com/CarGDev/strata
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/spf13/cobra v1.8.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
)
|
||||
18
compiler/go.sum
Normal file
18
compiler/go.sum
Normal file
@@ -0,0 +1,18 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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=
|
||||
304
compiler/internal/ast/nodes.go
Normal file
304
compiler/internal/ast/nodes.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package ast
|
||||
|
||||
// Node is the base interface for all AST nodes
|
||||
type Node interface {
|
||||
NodeType() string
|
||||
}
|
||||
|
||||
// StrataFile represents a parsed .strata file
|
||||
type StrataFile struct {
|
||||
Template *TemplateNode
|
||||
Script *ScriptNode
|
||||
Style *StyleNode
|
||||
}
|
||||
|
||||
func (s *StrataFile) NodeType() string { return "StrataFile" }
|
||||
|
||||
// TemplateNode represents the <template> block
|
||||
type TemplateNode struct {
|
||||
Children []Node
|
||||
}
|
||||
|
||||
func (t *TemplateNode) NodeType() string { return "Template" }
|
||||
|
||||
// ScriptNode represents the <script> block
|
||||
type ScriptNode struct {
|
||||
Content string
|
||||
Lang string // "ts" or "js"
|
||||
}
|
||||
|
||||
func (s *ScriptNode) NodeType() string { return "Script" }
|
||||
|
||||
// StyleNode represents the <style> block
|
||||
type StyleNode struct {
|
||||
Content string
|
||||
Lang string // "css", "scss", "sass", "less"
|
||||
Scoped bool
|
||||
}
|
||||
|
||||
func (s *StyleNode) NodeType() string { return "Style" }
|
||||
|
||||
// ElementNode represents an HTML element
|
||||
type ElementNode struct {
|
||||
Tag string
|
||||
Attributes map[string]string
|
||||
Directives []Directive
|
||||
Children []Node
|
||||
IsComponent bool
|
||||
}
|
||||
|
||||
func (e *ElementNode) NodeType() string { return "Element" }
|
||||
|
||||
// TextNode represents text content
|
||||
type TextNode struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func (t *TextNode) NodeType() string { return "Text" }
|
||||
|
||||
// InterpolationNode represents { expression } variable binding
|
||||
type InterpolationNode struct {
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (i *InterpolationNode) NodeType() string { return "Interpolation" }
|
||||
|
||||
// ForBlockNode represents { s-for item in items } ... { /s-for }
|
||||
type ForBlockNode struct {
|
||||
Item string // Loop variable name (e.g., "item")
|
||||
Index string // Optional index variable (e.g., "i" in "item, i")
|
||||
Iterable string // Expression to iterate (e.g., "items")
|
||||
Children []Node // Content inside the for block
|
||||
}
|
||||
|
||||
func (f *ForBlockNode) NodeType() string { return "ForBlock" }
|
||||
|
||||
// IfBlockNode represents { s-if } ... { s-elif } ... { s-else } ... { /s-if }
|
||||
type IfBlockNode struct {
|
||||
Condition string // The if condition
|
||||
Children []Node // Content when condition is true
|
||||
ElseIf []*ElseIfBranch
|
||||
Else []Node // Content for else branch
|
||||
}
|
||||
|
||||
func (i *IfBlockNode) NodeType() string { return "IfBlock" }
|
||||
|
||||
// ElseIfBranch represents an elif branch in conditional
|
||||
type ElseIfBranch struct {
|
||||
Condition string
|
||||
Children []Node
|
||||
}
|
||||
|
||||
// ImportNode represents { s-imp "@alias/file" }
|
||||
type ImportNode struct {
|
||||
Path string // Import path (e.g., "@components/Button")
|
||||
Alias string // Optional alias for the import
|
||||
}
|
||||
|
||||
func (i *ImportNode) NodeType() string { return "Import" }
|
||||
|
||||
// DirectiveType represents the type of directive
|
||||
type DirectiveType int
|
||||
|
||||
const (
|
||||
DirectiveConditional DirectiveType = iota // s-if, s-else-if, s-else
|
||||
DirectiveLoop // s-for
|
||||
DirectiveModel // s-model
|
||||
DirectiveEvent // s-on:event, @event
|
||||
DirectiveBind // :attr
|
||||
DirectiveClient // s-client:load, s-client:visible, etc.
|
||||
DirectiveFetch // s-fetch
|
||||
DirectiveRef // s-ref
|
||||
DirectiveHTML // s-html
|
||||
DirectiveStatic // s-static
|
||||
)
|
||||
|
||||
// Directive represents a Strata directive
|
||||
type Directive struct {
|
||||
Type DirectiveType
|
||||
Name string // Original attribute name
|
||||
Value string // Attribute value
|
||||
Arg string // Argument (e.g., "click" in @click)
|
||||
Modifiers []string // Modifiers (e.g., "prevent" in @click.prevent)
|
||||
}
|
||||
|
||||
// ComponentNode represents a component usage
|
||||
type ComponentNode struct {
|
||||
Name string
|
||||
Props map[string]string
|
||||
Directives []Directive
|
||||
Slots map[string][]Node
|
||||
}
|
||||
|
||||
func (c *ComponentNode) NodeType() string { return "Component" }
|
||||
|
||||
// SlotNode represents a <slot> element
|
||||
type SlotNode struct {
|
||||
Name string // default or named
|
||||
Fallback []Node
|
||||
}
|
||||
|
||||
func (s *SlotNode) NodeType() string { return "Slot" }
|
||||
|
||||
// ImportDeclaration represents an import statement
|
||||
type ImportDeclaration struct {
|
||||
Default string // Default import name
|
||||
Named []string // Named imports
|
||||
Source string // Import source path
|
||||
}
|
||||
|
||||
// ComponentDefinition represents a component's parsed definition
|
||||
type ComponentDefinition struct {
|
||||
Name string
|
||||
Props []PropDefinition
|
||||
Imports []ImportDeclaration
|
||||
Setup string // The setup function body
|
||||
}
|
||||
|
||||
// PropDefinition represents a component prop
|
||||
type PropDefinition struct {
|
||||
Name string
|
||||
Type string
|
||||
Required bool
|
||||
Default string
|
||||
}
|
||||
|
||||
// StoreDefinition represents a parsed store
|
||||
type StoreDefinition struct {
|
||||
Name string
|
||||
State map[string]string // Field name -> type
|
||||
Actions []ActionDefinition
|
||||
Encrypt []string // Fields to encrypt
|
||||
Persist []string // Fields to persist
|
||||
Shared []string // Fields to share across tabs
|
||||
}
|
||||
|
||||
// ActionDefinition represents a store action
|
||||
type ActionDefinition struct {
|
||||
Name string
|
||||
Params []string
|
||||
Body string
|
||||
Async bool
|
||||
}
|
||||
|
||||
// ToHTML converts a TemplateNode to HTML string
|
||||
func (t *TemplateNode) ToHTML() string {
|
||||
var result string
|
||||
for _, child := range t.Children {
|
||||
result += nodeToHTML(child)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// nodeToHTML converts any AST node to HTML
|
||||
func nodeToHTML(node Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *ElementNode:
|
||||
return elementToHTML(n)
|
||||
case *TextNode:
|
||||
return n.Content
|
||||
case *InterpolationNode:
|
||||
// For dev mode, output a placeholder that will be filled by JS
|
||||
return `<span data-strata-bind="` + n.Expression + `"></span>`
|
||||
case *ForBlockNode:
|
||||
return forBlockToHTML(n)
|
||||
case *IfBlockNode:
|
||||
return ifBlockToHTML(n)
|
||||
case *ImportNode:
|
||||
// Imports are handled at compile time, not rendered
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// forBlockToHTML converts a ForBlockNode to HTML with data attributes
|
||||
func forBlockToHTML(f *ForBlockNode) string {
|
||||
html := `<template data-strata-for="` + f.Item
|
||||
if f.Index != "" {
|
||||
html += `, ` + f.Index
|
||||
}
|
||||
html += ` in ` + f.Iterable + `">`
|
||||
for _, child := range f.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
return html
|
||||
}
|
||||
|
||||
// ifBlockToHTML converts an IfBlockNode to HTML with data attributes
|
||||
func ifBlockToHTML(i *IfBlockNode) string {
|
||||
html := `<template data-strata-if="` + i.Condition + `">`
|
||||
for _, child := range i.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
|
||||
// Handle elif branches
|
||||
for _, elif := range i.ElseIf {
|
||||
html += `<template data-strata-elif="` + elif.Condition + `">`
|
||||
for _, child := range elif.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
}
|
||||
|
||||
// Handle else branch
|
||||
if len(i.Else) > 0 {
|
||||
html += `<template data-strata-else>`
|
||||
for _, child := range i.Else {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
html += `</template>`
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// elementToHTML converts an ElementNode to HTML
|
||||
func elementToHTML(el *ElementNode) string {
|
||||
if el == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
html := "<" + el.Tag
|
||||
|
||||
// Add attributes
|
||||
for name, value := range el.Attributes {
|
||||
if value != "" {
|
||||
html += ` ` + name + `="` + value + `"`
|
||||
} else {
|
||||
html += ` ` + name
|
||||
}
|
||||
}
|
||||
|
||||
// Add directive attributes for dev mode debugging
|
||||
for _, dir := range el.Directives {
|
||||
html += ` data-strata-` + dir.Name + `="` + dir.Value + `"`
|
||||
}
|
||||
|
||||
html += ">"
|
||||
|
||||
// Add children
|
||||
for _, child := range el.Children {
|
||||
html += nodeToHTML(child)
|
||||
}
|
||||
|
||||
// Close tag (unless void element)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if !voidElements[el.Tag] {
|
||||
html += "</" + el.Tag + ">"
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
710
compiler/internal/compiler/static.go
Normal file
710
compiler/internal/compiler/static.go
Normal file
@@ -0,0 +1,710 @@
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
"github.com/CarGDev/strata/internal/parser"
|
||||
)
|
||||
|
||||
// StaticCompiler compiles .strata files to pure HTML at build time
|
||||
type StaticCompiler struct {
|
||||
projectDir string
|
||||
cache map[string]interface{}
|
||||
}
|
||||
|
||||
// NewStaticCompiler creates a new static compiler
|
||||
func NewStaticCompiler(projectDir string) *StaticCompiler {
|
||||
return &StaticCompiler{
|
||||
projectDir: projectDir,
|
||||
cache: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// CompileModule compiles a module (page or component) to static HTML
|
||||
type CompiledModule struct {
|
||||
Name string
|
||||
Route string
|
||||
HTML string
|
||||
CSS string
|
||||
Scope map[string]interface{}
|
||||
}
|
||||
|
||||
// CompilePage compiles a page directory to static HTML
|
||||
func (c *StaticCompiler) CompilePage(pageDir string) (*CompiledModule, error) {
|
||||
// Find .strata file
|
||||
pageName := filepath.Base(pageDir)
|
||||
strataPath := filepath.Join(pageDir, pageName+".strata")
|
||||
|
||||
// Read and parse .strata template
|
||||
strataContent, err := os.ReadFile(strataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read template: %w", err)
|
||||
}
|
||||
|
||||
p := parser.NewStrataParser(string(strataContent))
|
||||
file, err := p.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
// Load and execute .compiler.sts to get scope
|
||||
scope, err := c.loadCompilerScope(pageDir, pageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load compiler scope: %w", err)
|
||||
}
|
||||
|
||||
// Resolve template with scope - output clean HTML
|
||||
html := ""
|
||||
if file.Template != nil {
|
||||
html = c.resolveTemplate(file.Template, scope)
|
||||
}
|
||||
|
||||
// Load styles
|
||||
css := ""
|
||||
scssPath := filepath.Join(pageDir, pageName+".scss")
|
||||
if scssContent, err := os.ReadFile(scssPath); err == nil {
|
||||
css = string(scssContent)
|
||||
}
|
||||
|
||||
// Calculate route
|
||||
route := "/" + pageName
|
||||
if pageName == "index" {
|
||||
route = "/"
|
||||
}
|
||||
|
||||
return &CompiledModule{
|
||||
Name: pageName,
|
||||
Route: route,
|
||||
HTML: html,
|
||||
CSS: css,
|
||||
Scope: scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadCompilerScope loads and executes .compiler.sts to get template scope
|
||||
func (c *StaticCompiler) loadCompilerScope(dir, name string) (map[string]interface{}, error) {
|
||||
compilerPath := filepath.Join(dir, name+".compiler.sts")
|
||||
scope := make(map[string]interface{})
|
||||
|
||||
content, err := os.ReadFile(compilerPath)
|
||||
if err != nil {
|
||||
return scope, nil // No compiler file is OK
|
||||
}
|
||||
|
||||
// Parse the TypeScript/JavaScript exports
|
||||
// This is a simplified parser - in production, use a proper JS engine
|
||||
scope = c.parseCompilerExports(string(content))
|
||||
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// parseCompilerExports extracts exported values from .compiler.sts
|
||||
// This is a simplified implementation - production would use V8/QuickJS
|
||||
func (c *StaticCompiler) parseCompilerExports(content string) map[string]interface{} {
|
||||
scope := make(map[string]interface{})
|
||||
|
||||
// First pass: find all export const declarations
|
||||
exportRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*`)
|
||||
exportMatches := exportRegex.FindAllStringSubmatchIndex(content, -1)
|
||||
|
||||
for _, match := range exportMatches {
|
||||
if len(match) >= 4 {
|
||||
name := content[match[2]:match[3]]
|
||||
valueStart := match[1]
|
||||
|
||||
// Get the value - could be simple or complex (array/object)
|
||||
afterEquals := content[valueStart:]
|
||||
|
||||
// Check what type of value follows
|
||||
firstChar := ""
|
||||
for _, ch := range afterEquals {
|
||||
if ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r' {
|
||||
firstChar = string(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
switch firstChar {
|
||||
case "[":
|
||||
// Array - need to find matching bracket
|
||||
idx := strings.Index(afterEquals, "[")
|
||||
arrayStr := c.extractBalancedBrackets(afterEquals[idx:], '[', ']')
|
||||
if arrayStr != "" {
|
||||
value = c.parseValue(arrayStr)
|
||||
}
|
||||
case "{":
|
||||
// Object - need to find matching bracket
|
||||
idx := strings.Index(afterEquals, "{")
|
||||
objStr := c.extractBalancedBrackets(afterEquals[idx:], '{', '}')
|
||||
if objStr != "" {
|
||||
value = c.parseValue(objStr)
|
||||
}
|
||||
default:
|
||||
// Simple value - find until semicolon or newline
|
||||
endIdx := strings.Index(afterEquals, ";")
|
||||
if endIdx == -1 {
|
||||
endIdx = strings.Index(afterEquals, "\n")
|
||||
}
|
||||
if endIdx > 0 {
|
||||
value = c.parseValue(strings.TrimSpace(afterEquals[:endIdx]))
|
||||
}
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
scope[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match: export const name = await fetchFunction();
|
||||
// For async values, we need to execute the fetch
|
||||
asyncRegex := regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*await\s+(\w+)\s*\(\s*\)`)
|
||||
asyncMatches := asyncRegex.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range asyncMatches {
|
||||
if len(match) >= 3 {
|
||||
name := match[1]
|
||||
funcName := match[2]
|
||||
|
||||
// Check for known fetch functions
|
||||
if strings.Contains(funcName, "fetch") || strings.Contains(funcName, "Fetch") {
|
||||
// Look for the URL in service file or inline
|
||||
if data := c.executeFetch(content, funcName); data != nil {
|
||||
scope[name] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
// parseValue parses a JavaScript value to Go
|
||||
func (c *StaticCompiler) parseValue(value string) interface{} {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Boolean
|
||||
if value == "true" {
|
||||
return true
|
||||
}
|
||||
if value == "false" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Null
|
||||
if value == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Number
|
||||
if matched, _ := regexp.MatchString(`^-?\d+(\.\d+)?$`, value); matched {
|
||||
var num float64
|
||||
fmt.Sscanf(value, "%f", &num)
|
||||
return num
|
||||
}
|
||||
|
||||
// String (single or double quotes)
|
||||
if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) ||
|
||||
(strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
|
||||
// Array
|
||||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||
var arr []interface{}
|
||||
// Try to parse as JSON
|
||||
if err := json.Unmarshal([]byte(value), &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
// Simplified array parsing for objects
|
||||
return c.parseArray(value)
|
||||
}
|
||||
|
||||
// Object
|
||||
if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value), &obj); err == nil {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// parseArray parses a JavaScript array
|
||||
func (c *StaticCompiler) parseArray(value string) []interface{} {
|
||||
var result []interface{}
|
||||
|
||||
// Remove brackets
|
||||
inner := strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
|
||||
inner = strings.TrimSpace(inner)
|
||||
|
||||
if inner == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert JS syntax to JSON
|
||||
jsonStr := c.jsToJSON(value)
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// jsToJSON converts JavaScript object/array literals to valid JSON
|
||||
func (c *StaticCompiler) jsToJSON(value string) string {
|
||||
result := value
|
||||
|
||||
// Replace single quotes with double quotes (but not inside strings)
|
||||
// This is a simplified approach - handles most common cases
|
||||
var buf strings.Builder
|
||||
inString := false
|
||||
stringChar := rune(0)
|
||||
prevChar := rune(0)
|
||||
|
||||
for _, ch := range result {
|
||||
if !inString {
|
||||
if ch == '\'' {
|
||||
buf.WriteRune('"')
|
||||
inString = true
|
||||
stringChar = '\''
|
||||
} else if ch == '"' {
|
||||
buf.WriteRune(ch)
|
||||
inString = true
|
||||
stringChar = '"'
|
||||
} else {
|
||||
buf.WriteRune(ch)
|
||||
}
|
||||
} else {
|
||||
if ch == stringChar && prevChar != '\\' {
|
||||
buf.WriteRune('"')
|
||||
inString = false
|
||||
} else {
|
||||
buf.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
prevChar = ch
|
||||
}
|
||||
result = buf.String()
|
||||
|
||||
// Add quotes around unquoted object keys
|
||||
// Match: word followed by colon (but not inside strings or URLs)
|
||||
// Pattern: find keys at start of object or after comma/brace
|
||||
keyRegex := regexp.MustCompile(`([{,\s])(\w+)\s*:`)
|
||||
result = keyRegex.ReplaceAllString(result, `$1"$2":`)
|
||||
|
||||
// Handle arrays of strings like ['grass', 'poison']
|
||||
// Already handled by the single quote conversion above
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// executeFetch executes a fetch operation at build time or parses local function
|
||||
func (c *StaticCompiler) executeFetch(content, funcName string) interface{} {
|
||||
// First, look for a local function definition that returns data
|
||||
// Pattern: async function funcName() { ... return [...] }
|
||||
// or: function funcName() { ... return [...] }
|
||||
funcRegex := regexp.MustCompile(`(?:async\s+)?function\s+` + funcName + `\s*\([^)]*\)\s*\{`)
|
||||
if funcRegex.MatchString(content) {
|
||||
// Find the function body and look for return statement with array/object
|
||||
funcStart := funcRegex.FindStringIndex(content)
|
||||
if funcStart != nil {
|
||||
// Find the return statement with array literal
|
||||
remainder := content[funcStart[1]:]
|
||||
|
||||
// Find "return [" and then match brackets
|
||||
returnIdx := strings.Index(remainder, "return")
|
||||
if returnIdx != -1 {
|
||||
afterReturn := strings.TrimSpace(remainder[returnIdx+6:])
|
||||
if len(afterReturn) > 0 && afterReturn[0] == '[' {
|
||||
// Find the matching closing bracket
|
||||
arrayStr := c.extractBalancedBrackets(afterReturn, '[', ']')
|
||||
if arrayStr != "" {
|
||||
return c.parseValue(arrayStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for API URL patterns for actual network fetch
|
||||
urlRegex := regexp.MustCompile(`(?:fetch|call)\s*\(\s*['"]([^'"]+)['"]`)
|
||||
matches := urlRegex.FindStringSubmatch(content)
|
||||
|
||||
if len(matches) >= 2 {
|
||||
url := matches[1]
|
||||
return c.fetchURL(url)
|
||||
}
|
||||
|
||||
// Only fetch from network if no local data was found
|
||||
// and there's explicit fetch call syntax
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractBalancedBrackets extracts content between balanced brackets
|
||||
func (c *StaticCompiler) extractBalancedBrackets(content string, open, close rune) string {
|
||||
if len(content) == 0 || rune(content[0]) != open {
|
||||
return ""
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
stringChar := rune(0)
|
||||
|
||||
for i, ch := range content {
|
||||
// Handle string literals
|
||||
if !inString && (ch == '\'' || ch == '"' || ch == '`') {
|
||||
inString = true
|
||||
stringChar = ch
|
||||
} else if inString && ch == stringChar {
|
||||
// Check if escaped
|
||||
if i > 0 && content[i-1] != '\\' {
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
|
||||
if !inString {
|
||||
if ch == open {
|
||||
depth++
|
||||
} else if ch == close {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return content[:i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchURL fetches data from a URL at build time
|
||||
func (c *StaticCompiler) fetchURL(url string) interface{} {
|
||||
// Check cache first
|
||||
if cached, ok := c.cache[url]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Printf(" Warning: Failed to fetch %s: %v\n", url, err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
c.cache[url] = data
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// resolveTemplate resolves a template with the given scope to clean HTML
|
||||
func (c *StaticCompiler) resolveTemplate(template *ast.TemplateNode, scope map[string]interface{}) string {
|
||||
var result strings.Builder
|
||||
|
||||
for _, child := range template.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveNode resolves a single AST node with the given scope
|
||||
func (c *StaticCompiler) resolveNode(node ast.Node, scope map[string]interface{}) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *ast.ElementNode:
|
||||
return c.resolveElement(n, scope)
|
||||
|
||||
case *ast.TextNode:
|
||||
return n.Content
|
||||
|
||||
case *ast.InterpolationNode:
|
||||
// Resolve the expression and output the value directly
|
||||
value := c.resolveExpression(n.Expression, scope)
|
||||
return fmt.Sprintf("%v", value)
|
||||
|
||||
case *ast.ForBlockNode:
|
||||
return c.resolveForBlock(n, scope)
|
||||
|
||||
case *ast.IfBlockNode:
|
||||
return c.resolveIfBlock(n, scope)
|
||||
|
||||
case *ast.ImportNode:
|
||||
// Imports don't produce output
|
||||
return ""
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// resolveElement resolves an element node
|
||||
func (c *StaticCompiler) resolveElement(el *ast.ElementNode, scope map[string]interface{}) string {
|
||||
var html strings.Builder
|
||||
|
||||
html.WriteString("<")
|
||||
html.WriteString(el.Tag)
|
||||
|
||||
// Add attributes (skip strata directives in output)
|
||||
for name, value := range el.Attributes {
|
||||
// Skip strata-specific attributes in final output
|
||||
if strings.HasPrefix(name, "s-") || strings.HasPrefix(name, "@") {
|
||||
continue
|
||||
}
|
||||
if value != "" {
|
||||
html.WriteString(fmt.Sprintf(` %s="%s"`, name, value))
|
||||
} else {
|
||||
html.WriteString(" " + name)
|
||||
}
|
||||
}
|
||||
|
||||
html.WriteString(">")
|
||||
|
||||
// Resolve children
|
||||
for _, child := range el.Children {
|
||||
html.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
|
||||
// Close tag (unless void element)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if !voidElements[el.Tag] {
|
||||
html.WriteString("</")
|
||||
html.WriteString(el.Tag)
|
||||
html.WriteString(">")
|
||||
}
|
||||
|
||||
return html.String()
|
||||
}
|
||||
|
||||
// resolveForBlock resolves a for loop at build time
|
||||
func (c *StaticCompiler) resolveForBlock(f *ast.ForBlockNode, scope map[string]interface{}) string {
|
||||
var result strings.Builder
|
||||
|
||||
// Get the iterable from scope
|
||||
iterable := c.resolveExpression(f.Iterable, scope)
|
||||
|
||||
// Convert to slice
|
||||
var items []interface{}
|
||||
switch v := iterable.(type) {
|
||||
case []interface{}:
|
||||
items = v
|
||||
case []map[string]interface{}:
|
||||
for _, item := range v {
|
||||
items = append(items, item)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// Check if it has a "results" key (common in APIs like PokeAPI)
|
||||
if results, ok := v["results"]; ok {
|
||||
if arr, ok := results.([]interface{}); ok {
|
||||
items = arr
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Not iterable
|
||||
return ""
|
||||
}
|
||||
|
||||
// Iterate and resolve children
|
||||
for i, item := range items {
|
||||
// Create new scope with loop variables
|
||||
loopScope := make(map[string]interface{})
|
||||
for k, v := range scope {
|
||||
loopScope[k] = v
|
||||
}
|
||||
loopScope[f.Item] = item
|
||||
if f.Index != "" {
|
||||
loopScope[f.Index] = i
|
||||
}
|
||||
|
||||
// Resolve children in loop scope
|
||||
for _, child := range f.Children {
|
||||
result.WriteString(c.resolveNode(child, loopScope))
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveIfBlock resolves a conditional at build time
|
||||
func (c *StaticCompiler) resolveIfBlock(i *ast.IfBlockNode, scope map[string]interface{}) string {
|
||||
// Evaluate the condition
|
||||
if c.evaluateCondition(i.Condition, scope) {
|
||||
var result strings.Builder
|
||||
for _, child := range i.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// Check elif branches
|
||||
for _, elif := range i.ElseIf {
|
||||
if c.evaluateCondition(elif.Condition, scope) {
|
||||
var result strings.Builder
|
||||
for _, child := range elif.Children {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Else branch
|
||||
if len(i.Else) > 0 {
|
||||
var result strings.Builder
|
||||
for _, child := range i.Else {
|
||||
result.WriteString(c.resolveNode(child, scope))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveExpression resolves an expression against the scope
|
||||
func (c *StaticCompiler) resolveExpression(expr string, scope map[string]interface{}) interface{} {
|
||||
expr = strings.TrimSpace(expr)
|
||||
|
||||
// Direct variable lookup
|
||||
if val, ok := scope[expr]; ok {
|
||||
return val
|
||||
}
|
||||
|
||||
// Property access (e.g., "item.name")
|
||||
if strings.Contains(expr, ".") {
|
||||
parts := strings.Split(expr, ".")
|
||||
current := scope[parts[0]]
|
||||
|
||||
for i := 1; i < len(parts) && current != nil; i++ {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
current = v[parts[i]]
|
||||
default:
|
||||
return expr
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Return the expression as-is if not found
|
||||
return expr
|
||||
}
|
||||
|
||||
// evaluateCondition evaluates a condition expression
|
||||
func (c *StaticCompiler) evaluateCondition(condition string, scope map[string]interface{}) bool {
|
||||
condition = strings.TrimSpace(condition)
|
||||
|
||||
// Simple boolean variable
|
||||
if val, ok := scope[condition]; ok {
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != ""
|
||||
case int, int64, float64:
|
||||
return v != 0
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Negation
|
||||
if strings.HasPrefix(condition, "!") {
|
||||
return !c.evaluateCondition(condition[1:], scope)
|
||||
}
|
||||
|
||||
// Comparison: ==, !=, >, <, >=, <=
|
||||
for _, op := range []string{"===", "==", "!==", "!=", ">=", "<=", ">", "<"} {
|
||||
if strings.Contains(condition, op) {
|
||||
parts := strings.SplitN(condition, op, 2)
|
||||
if len(parts) == 2 {
|
||||
left := c.resolveExpression(strings.TrimSpace(parts[0]), scope)
|
||||
right := c.resolveExpression(strings.TrimSpace(parts[1]), scope)
|
||||
return c.compare(left, right, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property access that resolves to truthy
|
||||
val := c.resolveExpression(condition, scope)
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != "" && v != condition // Not found returns expression itself
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// compare compares two values with the given operator
|
||||
func (c *StaticCompiler) compare(left, right interface{}, op string) bool {
|
||||
switch op {
|
||||
case "==", "===":
|
||||
return fmt.Sprintf("%v", left) == fmt.Sprintf("%v", right)
|
||||
case "!=", "!==":
|
||||
return fmt.Sprintf("%v", left) != fmt.Sprintf("%v", right)
|
||||
case ">":
|
||||
return toFloat(left) > toFloat(right)
|
||||
case "<":
|
||||
return toFloat(left) < toFloat(right)
|
||||
case ">=":
|
||||
return toFloat(left) >= toFloat(right)
|
||||
case "<=":
|
||||
return toFloat(left) <= toFloat(right)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case string:
|
||||
var f float64
|
||||
fmt.Sscanf(n, "%f", &f)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
164
compiler/internal/generator/html.go
Normal file
164
compiler/internal/generator/html.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// HTMLGenerator generates index.html only in dist folder
|
||||
type HTMLGenerator struct {
|
||||
projectDir string
|
||||
distDir string
|
||||
injector *ScriptInjector
|
||||
encryptionKey []byte
|
||||
}
|
||||
|
||||
// NewHTMLGenerator creates a new HTML generator
|
||||
func NewHTMLGenerator(projectDir, distDir string) *HTMLGenerator {
|
||||
return &HTMLGenerator{
|
||||
projectDir: projectDir,
|
||||
distDir: distDir,
|
||||
injector: NewScriptInjector(projectDir),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates the index.html in dist folder
|
||||
func (hg *HTMLGenerator) Generate(config *BuildConfig) error {
|
||||
// Load injected scripts
|
||||
if err := hg.injector.LoadScripts(); err != nil {
|
||||
return fmt.Errorf("failed to load injected scripts: %w", err)
|
||||
}
|
||||
|
||||
// Generate encryption key at build time
|
||||
hg.encryptionKey = make([]byte, 32)
|
||||
if _, err := rand.Read(hg.encryptionKey); err != nil {
|
||||
return fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Create dist directory
|
||||
if err := os.MkdirAll(hg.distDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dist directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate HTML
|
||||
html, err := hg.buildHTML(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inject scripts
|
||||
html = hg.injector.InjectIntoHTML(html)
|
||||
|
||||
// Write to dist
|
||||
indexPath := filepath.Join(hg.distDir, "index.html")
|
||||
if err := os.WriteFile(indexPath, []byte(html), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildConfig holds build configuration
|
||||
type BuildConfig struct {
|
||||
Title string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIBaseURL string
|
||||
DevMode bool
|
||||
Assets AssetManifest
|
||||
}
|
||||
|
||||
// AssetManifest tracks generated assets
|
||||
type AssetManifest struct {
|
||||
JS []string
|
||||
CSS []string
|
||||
}
|
||||
|
||||
func (hg *HTMLGenerator) buildHTML(config *BuildConfig) (string, error) {
|
||||
tmpl := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<title>{{.Title}}</title>
|
||||
<base href="{{.BaseURL}}">
|
||||
|
||||
{{range .Assets.CSS}}
|
||||
<link rel="stylesheet" href="{{.}}">
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
// Strata config (build-time injected)
|
||||
window.__STRATA_CONFIG__ = {
|
||||
apiBaseUrl: "{{.APIBaseURL}}",
|
||||
devMode: {{.DevMode}},
|
||||
encryptionKey: [{{.EncryptionKeyArray}}]
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- Strata Runtime -->
|
||||
<script type="module" src="/assets/js/runtime.js"></script>
|
||||
|
||||
{{range .Assets.JS}}
|
||||
<script type="module" src="{{.}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Convert encryption key to array format for JS
|
||||
keyArray := make([]string, len(hg.encryptionKey))
|
||||
for i, b := range hg.encryptionKey {
|
||||
keyArray[i] = fmt.Sprintf("%d", b)
|
||||
}
|
||||
keyArrayStr := ""
|
||||
for i, k := range keyArray {
|
||||
if i > 0 {
|
||||
keyArrayStr += ","
|
||||
}
|
||||
keyArrayStr += k
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Title string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIBaseURL string
|
||||
DevMode bool
|
||||
Assets AssetManifest
|
||||
EncryptionKeyArray string
|
||||
}{
|
||||
Title: config.Title,
|
||||
Description: config.Description,
|
||||
BaseURL: config.BaseURL,
|
||||
APIBaseURL: config.APIBaseURL,
|
||||
DevMode: config.DevMode,
|
||||
Assets: config.Assets,
|
||||
EncryptionKeyArray: keyArrayStr,
|
||||
}
|
||||
|
||||
t, err := template.New("html").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// GetEncryptionKeyHex returns the encryption key as hex for debugging
|
||||
func (hg *HTMLGenerator) GetEncryptionKeyHex() string {
|
||||
return hex.EncodeToString(hg.encryptionKey)
|
||||
}
|
||||
181
compiler/internal/generator/injector.go
Normal file
181
compiler/internal/generator/injector.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InjectedScript represents a script file to be injected
|
||||
type InjectedScript struct {
|
||||
Name string
|
||||
Content string
|
||||
Position string // "head" or "body"
|
||||
Priority int // Lower = earlier injection
|
||||
}
|
||||
|
||||
// ScriptInjector handles the injectedscripts/ directory
|
||||
type ScriptInjector struct {
|
||||
scriptsDir string
|
||||
scripts []InjectedScript
|
||||
}
|
||||
|
||||
// NewScriptInjector creates a new script injector
|
||||
func NewScriptInjector(projectDir string) *ScriptInjector {
|
||||
return &ScriptInjector{
|
||||
scriptsDir: filepath.Join(projectDir, "src", "injectedscripts"),
|
||||
scripts: make([]InjectedScript, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadScripts loads all scripts from injectedscripts/ directory
|
||||
func (si *ScriptInjector) LoadScripts() error {
|
||||
if _, err := os.Stat(si.scriptsDir); os.IsNotExist(err) {
|
||||
return nil // No injectedscripts directory, that's fine
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(si.scriptsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".js") {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(si.scriptsDir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
script := si.parseScript(name, string(content))
|
||||
si.scripts = append(si.scripts, script)
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
sort.Slice(si.scripts, func(i, j int) bool {
|
||||
return si.scripts[i].Priority < si.scripts[j].Priority
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseScript parses a script file and extracts metadata from comments
|
||||
func (si *ScriptInjector) parseScript(name string, content string) InjectedScript {
|
||||
script := InjectedScript{
|
||||
Name: strings.TrimSuffix(name, ".js"),
|
||||
Content: content,
|
||||
Position: "head", // default to head
|
||||
Priority: 100, // default priority
|
||||
}
|
||||
|
||||
// Parse special comments at the top
|
||||
// /* @position: body */
|
||||
// /* @priority: 10 */
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "/*") && !strings.HasPrefix(line, "//") {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.Contains(line, "@position:") {
|
||||
if strings.Contains(line, "body") {
|
||||
script.Position = "body"
|
||||
} else {
|
||||
script.Position = "head"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(line, "@priority:") {
|
||||
// Extract priority number
|
||||
parts := strings.Split(line, "@priority:")
|
||||
if len(parts) > 1 {
|
||||
priority := strings.TrimSpace(parts[1])
|
||||
priority = strings.TrimSuffix(priority, "*/")
|
||||
priority = strings.TrimSpace(priority)
|
||||
var p int
|
||||
if _, err := parseIntFromString(priority, &p); err == nil {
|
||||
script.Priority = p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return script
|
||||
}
|
||||
|
||||
// GetHeadScripts returns scripts for <head>
|
||||
func (si *ScriptInjector) GetHeadScripts() []InjectedScript {
|
||||
var result []InjectedScript
|
||||
for _, s := range si.scripts {
|
||||
if s.Position == "head" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBodyScripts returns scripts for end of <body>
|
||||
func (si *ScriptInjector) GetBodyScripts() []InjectedScript {
|
||||
var result []InjectedScript
|
||||
for _, s := range si.scripts {
|
||||
if s.Position == "body" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// InjectIntoHTML injects scripts into HTML content
|
||||
func (si *ScriptInjector) InjectIntoHTML(html string) string {
|
||||
// Inject head scripts before </head>
|
||||
headScripts := si.GetHeadScripts()
|
||||
if len(headScripts) > 0 {
|
||||
var headContent strings.Builder
|
||||
for _, s := range headScripts {
|
||||
headContent.WriteString("<!-- Injected: ")
|
||||
headContent.WriteString(s.Name)
|
||||
headContent.WriteString(" -->\n")
|
||||
headContent.WriteString(s.Content)
|
||||
headContent.WriteString("\n")
|
||||
}
|
||||
html = strings.Replace(html, "</head>", headContent.String()+"</head>", 1)
|
||||
}
|
||||
|
||||
// Inject body scripts before </body>
|
||||
bodyScripts := si.GetBodyScripts()
|
||||
if len(bodyScripts) > 0 {
|
||||
var bodyContent strings.Builder
|
||||
for _, s := range bodyScripts {
|
||||
bodyContent.WriteString("<!-- Injected: ")
|
||||
bodyContent.WriteString(s.Name)
|
||||
bodyContent.WriteString(" -->\n")
|
||||
bodyContent.WriteString(s.Content)
|
||||
bodyContent.WriteString("\n")
|
||||
}
|
||||
html = strings.Replace(html, "</body>", bodyContent.String()+"</body>", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
func parseIntFromString(s string, target *int) (int, error) {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
*target = n
|
||||
return n, nil
|
||||
}
|
||||
688
compiler/internal/parser/strata.go
Normal file
688
compiler/internal/parser/strata.go
Normal file
@@ -0,0 +1,688 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
)
|
||||
|
||||
// StrataParser parses .strata files
|
||||
type StrataParser struct {
|
||||
source string
|
||||
pos int
|
||||
template *ast.TemplateNode
|
||||
script *ast.ScriptNode
|
||||
style *ast.StyleNode
|
||||
}
|
||||
|
||||
// NewStrataParser creates a new parser
|
||||
func NewStrataParser(source string) *StrataParser {
|
||||
return &StrataParser{
|
||||
source: source,
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parses a .strata file into AST nodes
|
||||
func (p *StrataParser) Parse() (*ast.StrataFile, error) {
|
||||
file := &ast.StrataFile{}
|
||||
|
||||
// Extract <template>, <script>, and <style> blocks
|
||||
templateContent := p.extractBlock("template")
|
||||
scriptContent := p.extractBlock("script")
|
||||
styleContent := p.extractBlock("style")
|
||||
|
||||
// Parse template
|
||||
if templateContent != "" {
|
||||
template, err := p.parseTemplate(templateContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Template = template
|
||||
}
|
||||
|
||||
// Parse script
|
||||
if scriptContent != "" {
|
||||
script, err := p.parseScript(scriptContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Script = script
|
||||
}
|
||||
|
||||
// Parse style
|
||||
if styleContent != "" {
|
||||
style, err := p.parseStyle(styleContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Style = style
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// extractBlock extracts content between <tag> and </tag>
|
||||
func (p *StrataParser) extractBlock(tag string) string {
|
||||
// Match opening tag with optional attributes
|
||||
openPattern := regexp.MustCompile(`<` + tag + `(?:\s+[^>]*)?>`)
|
||||
closePattern := regexp.MustCompile(`</` + tag + `>`)
|
||||
|
||||
openMatch := openPattern.FindStringIndex(p.source)
|
||||
if openMatch == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
closeMatch := closePattern.FindStringIndex(p.source[openMatch[1]:])
|
||||
if closeMatch == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract content between tags
|
||||
start := openMatch[1]
|
||||
end := openMatch[1] + closeMatch[0]
|
||||
|
||||
return strings.TrimSpace(p.source[start:end])
|
||||
}
|
||||
|
||||
// parseTemplate parses the template content
|
||||
func (p *StrataParser) parseTemplate(content string) (*ast.TemplateNode, error) {
|
||||
template := &ast.TemplateNode{
|
||||
Children: make([]ast.Node, 0),
|
||||
}
|
||||
|
||||
// Parse HTML with Strata directives
|
||||
nodes, err := p.parseHTML(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template.Children = nodes
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// parseHTML parses HTML content into AST nodes
|
||||
func (p *StrataParser) parseHTML(content string) ([]ast.Node, error) {
|
||||
nodes := make([]ast.Node, 0)
|
||||
|
||||
// Simple tokenization - in production, use proper HTML parser
|
||||
pos := 0
|
||||
for pos < len(content) {
|
||||
// Skip whitespace
|
||||
for pos < len(content) && isWhitespace(content[pos]) {
|
||||
pos++
|
||||
}
|
||||
|
||||
if pos >= len(content) {
|
||||
break
|
||||
}
|
||||
|
||||
// Strata block directive { s-* } or variable interpolation { var }
|
||||
if content[pos] == '{' && pos+1 < len(content) && content[pos+1] != '{' {
|
||||
// Find closing }
|
||||
end := strings.Index(content[pos:], "}")
|
||||
if end != -1 {
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+end])
|
||||
|
||||
// Check for block directives
|
||||
if strings.HasPrefix(inner, "s-for ") {
|
||||
// Parse { s-for item in items } ... { /s-for }
|
||||
node, newPos, err := p.parseForBlock(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
pos = newPos
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "s-if ") {
|
||||
// Parse { s-if cond } ... { /s-if }
|
||||
node, newPos, err := p.parseIfBlock(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
pos = newPos
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "s-imp ") {
|
||||
// Parse { s-imp "@alias/file" }
|
||||
importPath := strings.TrimSpace(inner[6:])
|
||||
importPath = strings.Trim(importPath, `"'`)
|
||||
nodes = append(nodes, &ast.ImportNode{
|
||||
Path: importPath,
|
||||
})
|
||||
pos = pos + end + 1
|
||||
continue
|
||||
} else if strings.HasPrefix(inner, "/s-") || strings.HasPrefix(inner, "s-elif") || inner == "s-else" {
|
||||
// Closing or branch tags - handled by parent parser
|
||||
break
|
||||
} else {
|
||||
// Variable interpolation { var }
|
||||
nodes = append(nodes, &ast.InterpolationNode{
|
||||
Expression: inner,
|
||||
})
|
||||
pos = pos + end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy {{ }} interpolation (for backwards compatibility)
|
||||
if pos+1 < len(content) && content[pos:pos+2] == "{{" {
|
||||
end := strings.Index(content[pos:], "}}")
|
||||
if end != -1 {
|
||||
expr := strings.TrimSpace(content[pos+2 : pos+end])
|
||||
nodes = append(nodes, &ast.InterpolationNode{
|
||||
Expression: expr,
|
||||
})
|
||||
pos = pos + end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// HTML Element
|
||||
if content[pos] == '<' {
|
||||
// Comment
|
||||
if pos+4 < len(content) && content[pos:pos+4] == "<!--" {
|
||||
end := strings.Index(content[pos:], "-->")
|
||||
if end != -1 {
|
||||
pos = pos + end + 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Closing tag
|
||||
if pos+2 < len(content) && content[pos+1] == '/' {
|
||||
end := strings.Index(content[pos:], ">")
|
||||
if end != -1 {
|
||||
pos = pos + end + 1
|
||||
}
|
||||
break // Return to parent
|
||||
}
|
||||
|
||||
// Opening tag
|
||||
node, newPos, err := p.parseElement(content, pos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node != nil {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
pos = newPos
|
||||
continue
|
||||
}
|
||||
|
||||
// Text content
|
||||
textEnd := strings.IndexAny(content[pos:], "<{")
|
||||
if textEnd == -1 {
|
||||
textEnd = len(content) - pos
|
||||
}
|
||||
text := strings.TrimSpace(content[pos : pos+textEnd])
|
||||
if text != "" {
|
||||
nodes = append(nodes, &ast.TextNode{Content: text})
|
||||
}
|
||||
pos = pos + textEnd
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// parseForBlock parses { s-for item in items } ... { /s-for }
|
||||
func (p *StrataParser) parseForBlock(content string, pos int) (*ast.ForBlockNode, int, error) {
|
||||
// Find opening tag end
|
||||
openEnd := strings.Index(content[pos:], "}")
|
||||
if openEnd == -1 {
|
||||
return nil, pos, nil
|
||||
}
|
||||
|
||||
// Parse the for expression: "s-for item in items" or "s-for item, i in items"
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
|
||||
inner = strings.TrimPrefix(inner, "s-for ")
|
||||
inner = strings.TrimSpace(inner)
|
||||
|
||||
// Split by " in "
|
||||
parts := strings.SplitN(inner, " in ", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, pos + openEnd + 1, nil
|
||||
}
|
||||
|
||||
forNode := &ast.ForBlockNode{
|
||||
Iterable: strings.TrimSpace(parts[1]),
|
||||
}
|
||||
|
||||
// Parse item and optional index: "item" or "item, i"
|
||||
itemPart := strings.TrimSpace(parts[0])
|
||||
if strings.Contains(itemPart, ",") {
|
||||
itemParts := strings.SplitN(itemPart, ",", 2)
|
||||
forNode.Item = strings.TrimSpace(itemParts[0])
|
||||
forNode.Index = strings.TrimSpace(itemParts[1])
|
||||
} else {
|
||||
forNode.Item = itemPart
|
||||
}
|
||||
|
||||
// Find content between opening and closing tags
|
||||
startPos := pos + openEnd + 1
|
||||
closeTag := "{ /s-for }"
|
||||
closeTagAlt := "{/s-for}"
|
||||
|
||||
// Search for closing tag
|
||||
closePos := strings.Index(content[startPos:], closeTag)
|
||||
if closePos == -1 {
|
||||
closePos = strings.Index(content[startPos:], closeTagAlt)
|
||||
if closePos == -1 {
|
||||
// Try more lenient matching
|
||||
closePos = p.findClosingTag(content[startPos:], "/s-for")
|
||||
}
|
||||
}
|
||||
|
||||
if closePos == -1 {
|
||||
return nil, pos + openEnd + 1, nil
|
||||
}
|
||||
|
||||
// Parse children
|
||||
childContent := content[startPos : startPos+closePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
forNode.Children = children
|
||||
|
||||
// Calculate new position after closing tag
|
||||
newPos := startPos + closePos
|
||||
// Skip past the closing tag
|
||||
closeEnd := strings.Index(content[newPos:], "}")
|
||||
if closeEnd != -1 {
|
||||
newPos = newPos + closeEnd + 1
|
||||
}
|
||||
|
||||
return forNode, newPos, nil
|
||||
}
|
||||
|
||||
// parseIfBlock parses { s-if cond } ... { s-elif cond } ... { s-else } ... { /s-if }
|
||||
func (p *StrataParser) parseIfBlock(content string, pos int) (*ast.IfBlockNode, int, error) {
|
||||
// Find opening tag end
|
||||
openEnd := strings.Index(content[pos:], "}")
|
||||
if openEnd == -1 {
|
||||
return nil, pos, nil
|
||||
}
|
||||
|
||||
// Parse the if condition
|
||||
inner := strings.TrimSpace(content[pos+1 : pos+openEnd])
|
||||
condition := strings.TrimPrefix(inner, "s-if ")
|
||||
condition = strings.TrimSpace(condition)
|
||||
|
||||
ifNode := &ast.IfBlockNode{
|
||||
Condition: condition,
|
||||
ElseIf: make([]*ast.ElseIfBranch, 0),
|
||||
}
|
||||
|
||||
// Find content and branches
|
||||
startPos := pos + openEnd + 1
|
||||
currentPos := startPos
|
||||
|
||||
// Parse until we hit /s-if, collecting elif and else branches
|
||||
for currentPos < len(content) {
|
||||
// Look for next branch or closing tag
|
||||
nextBrace := strings.Index(content[currentPos:], "{")
|
||||
if nextBrace == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
bracePos := currentPos + nextBrace
|
||||
braceEnd := strings.Index(content[bracePos:], "}")
|
||||
if braceEnd == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
braceContent := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
|
||||
|
||||
if strings.HasPrefix(braceContent, "/s-if") {
|
||||
// Closing tag - parse content up to here
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
// Add children to appropriate branch
|
||||
if len(ifNode.ElseIf) == 0 && len(ifNode.Else) == 0 {
|
||||
ifNode.Children = children
|
||||
}
|
||||
|
||||
return ifNode, bracePos + braceEnd + 1, nil
|
||||
} else if strings.HasPrefix(braceContent, "s-elif ") {
|
||||
// Parse content for previous branch
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
if len(ifNode.ElseIf) == 0 {
|
||||
ifNode.Children = children
|
||||
} else {
|
||||
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
|
||||
}
|
||||
|
||||
// Add new elif branch
|
||||
elifCond := strings.TrimPrefix(braceContent, "s-elif ")
|
||||
elifCond = strings.TrimSpace(elifCond)
|
||||
ifNode.ElseIf = append(ifNode.ElseIf, &ast.ElseIfBranch{
|
||||
Condition: elifCond,
|
||||
})
|
||||
|
||||
startPos = bracePos + braceEnd + 1
|
||||
currentPos = startPos
|
||||
continue
|
||||
} else if braceContent == "s-else" {
|
||||
// Parse content for previous branch
|
||||
childContent := content[startPos:bracePos]
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
|
||||
if len(ifNode.ElseIf) == 0 {
|
||||
ifNode.Children = children
|
||||
} else {
|
||||
ifNode.ElseIf[len(ifNode.ElseIf)-1].Children = children
|
||||
}
|
||||
|
||||
// Find closing tag and parse else content
|
||||
elseStart := bracePos + braceEnd + 1
|
||||
closePos := p.findClosingTag(content[elseStart:], "/s-if")
|
||||
if closePos != -1 {
|
||||
elseContent := content[elseStart : elseStart+closePos]
|
||||
elseChildren, err := p.parseHTML(elseContent)
|
||||
if err != nil {
|
||||
return nil, pos, err
|
||||
}
|
||||
ifNode.Else = elseChildren
|
||||
|
||||
// Skip to after closing tag
|
||||
closeEnd := strings.Index(content[elseStart+closePos:], "}")
|
||||
if closeEnd != -1 {
|
||||
return ifNode, elseStart + closePos + closeEnd + 1, nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
currentPos = bracePos + braceEnd + 1
|
||||
}
|
||||
|
||||
return ifNode, currentPos, nil
|
||||
}
|
||||
|
||||
// findClosingTag finds position of { /tag } allowing for whitespace
|
||||
func (p *StrataParser) findClosingTag(content string, tag string) int {
|
||||
pos := 0
|
||||
for pos < len(content) {
|
||||
bracePos := strings.Index(content[pos:], "{")
|
||||
if bracePos == -1 {
|
||||
return -1
|
||||
}
|
||||
bracePos += pos
|
||||
|
||||
braceEnd := strings.Index(content[bracePos:], "}")
|
||||
if braceEnd == -1 {
|
||||
return -1
|
||||
}
|
||||
|
||||
inner := strings.TrimSpace(content[bracePos+1 : bracePos+braceEnd])
|
||||
if inner == tag || strings.TrimSpace(inner) == tag {
|
||||
return bracePos
|
||||
}
|
||||
|
||||
pos = bracePos + braceEnd + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseElement parses an HTML element
|
||||
func (p *StrataParser) parseElement(content string, pos int) (ast.Node, int, error) {
|
||||
// Find tag name
|
||||
tagStart := pos + 1
|
||||
tagEnd := tagStart
|
||||
for tagEnd < len(content) && !isWhitespace(content[tagEnd]) && content[tagEnd] != '>' && content[tagEnd] != '/' {
|
||||
tagEnd++
|
||||
}
|
||||
|
||||
tagName := content[tagStart:tagEnd]
|
||||
|
||||
element := &ast.ElementNode{
|
||||
Tag: tagName,
|
||||
Attributes: make(map[string]string),
|
||||
Directives: make([]ast.Directive, 0),
|
||||
Children: make([]ast.Node, 0),
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
attrPos := tagEnd
|
||||
for attrPos < len(content) && content[attrPos] != '>' && content[attrPos] != '/' {
|
||||
// Skip whitespace
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
|
||||
if attrPos >= len(content) || content[attrPos] == '>' || content[attrPos] == '/' {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse attribute name
|
||||
nameStart := attrPos
|
||||
for attrPos < len(content) && !isWhitespace(content[attrPos]) && content[attrPos] != '=' && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
attrName := content[nameStart:attrPos]
|
||||
|
||||
// Parse attribute value
|
||||
attrValue := ""
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
if attrPos < len(content) && content[attrPos] == '=' {
|
||||
attrPos++
|
||||
for attrPos < len(content) && isWhitespace(content[attrPos]) {
|
||||
attrPos++
|
||||
}
|
||||
if attrPos < len(content) && (content[attrPos] == '"' || content[attrPos] == '\'') {
|
||||
quote := content[attrPos]
|
||||
attrPos++
|
||||
valueStart := attrPos
|
||||
for attrPos < len(content) && content[attrPos] != quote {
|
||||
attrPos++
|
||||
}
|
||||
attrValue = content[valueStart:attrPos]
|
||||
attrPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Check for directives
|
||||
if directive := p.parseDirective(attrName, attrValue); directive != nil {
|
||||
element.Directives = append(element.Directives, *directive)
|
||||
} else {
|
||||
element.Attributes[attrName] = attrValue
|
||||
}
|
||||
}
|
||||
|
||||
// Self-closing tag
|
||||
if attrPos < len(content) && content[attrPos] == '/' {
|
||||
attrPos++
|
||||
for attrPos < len(content) && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
return element, attrPos + 1, nil
|
||||
}
|
||||
|
||||
// Find closing >
|
||||
for attrPos < len(content) && content[attrPos] != '>' {
|
||||
attrPos++
|
||||
}
|
||||
attrPos++ // Skip >
|
||||
|
||||
// Void elements (no children)
|
||||
voidElements := map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true,
|
||||
"embed": true, "hr": true, "img": true, "input": true,
|
||||
"link": true, "meta": true, "param": true, "source": true,
|
||||
"track": true, "wbr": true,
|
||||
}
|
||||
|
||||
if voidElements[tagName] {
|
||||
return element, attrPos, nil
|
||||
}
|
||||
|
||||
// Parse children
|
||||
childContent := ""
|
||||
depth := 1
|
||||
childStart := attrPos
|
||||
for attrPos < len(content) && depth > 0 {
|
||||
if attrPos+1 < len(content) && content[attrPos] == '<' {
|
||||
if content[attrPos+1] == '/' {
|
||||
// Closing tag
|
||||
closeEnd := strings.Index(content[attrPos:], ">")
|
||||
if closeEnd != -1 {
|
||||
closeTag := content[attrPos+2 : attrPos+closeEnd]
|
||||
closeTag = strings.TrimSpace(closeTag)
|
||||
if closeTag == tagName {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
childContent = content[childStart:attrPos]
|
||||
attrPos = attrPos + closeEnd + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if content[attrPos+1] != '!' {
|
||||
// Opening tag - check if same tag
|
||||
openEnd := attrPos + 1
|
||||
for openEnd < len(content) && !isWhitespace(content[openEnd]) && content[openEnd] != '>' && content[openEnd] != '/' {
|
||||
openEnd++
|
||||
}
|
||||
openTag := content[attrPos+1 : openEnd]
|
||||
if openTag == tagName {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
}
|
||||
attrPos++
|
||||
}
|
||||
|
||||
// Parse children
|
||||
if childContent != "" {
|
||||
children, err := p.parseHTML(childContent)
|
||||
if err != nil {
|
||||
return nil, attrPos, err
|
||||
}
|
||||
element.Children = children
|
||||
}
|
||||
|
||||
return element, attrPos, nil
|
||||
}
|
||||
|
||||
// parseDirective parses Strata directives
|
||||
func (p *StrataParser) parseDirective(name, value string) *ast.Directive {
|
||||
directive := &ast.Directive{
|
||||
Name: name,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// s-if, s-else-if, s-else
|
||||
if name == "s-if" || name == "s-else-if" || name == "s-else" {
|
||||
directive.Type = ast.DirectiveConditional
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-for
|
||||
if name == "s-for" {
|
||||
directive.Type = ast.DirectiveLoop
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-model (two-way binding)
|
||||
if name == "s-model" {
|
||||
directive.Type = ast.DirectiveModel
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-on:event or @event (event handlers)
|
||||
if strings.HasPrefix(name, "s-on:") || strings.HasPrefix(name, "@") {
|
||||
directive.Type = ast.DirectiveEvent
|
||||
if strings.HasPrefix(name, "@") {
|
||||
directive.Arg = name[1:]
|
||||
} else {
|
||||
directive.Arg = name[5:]
|
||||
}
|
||||
return directive
|
||||
}
|
||||
|
||||
// :attr (attribute binding)
|
||||
if strings.HasPrefix(name, ":") {
|
||||
directive.Type = ast.DirectiveBind
|
||||
directive.Arg = name[1:]
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-client:* (hydration strategies)
|
||||
if strings.HasPrefix(name, "s-client:") {
|
||||
directive.Type = ast.DirectiveClient
|
||||
directive.Arg = name[9:]
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-fetch (data fetching)
|
||||
if name == "s-fetch" {
|
||||
directive.Type = ast.DirectiveFetch
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-ref
|
||||
if name == "s-ref" {
|
||||
directive.Type = ast.DirectiveRef
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-html (raw HTML)
|
||||
if name == "s-html" {
|
||||
directive.Type = ast.DirectiveHTML
|
||||
return directive
|
||||
}
|
||||
|
||||
// s-static (no JS)
|
||||
if name == "s-static" {
|
||||
directive.Type = ast.DirectiveStatic
|
||||
return directive
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseScript parses the script content
|
||||
func (p *StrataParser) parseScript(content string) (*ast.ScriptNode, error) {
|
||||
return &ast.ScriptNode{
|
||||
Content: content,
|
||||
Lang: "ts", // Default to TypeScript
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseStyle parses the style content
|
||||
func (p *StrataParser) parseStyle(content string) (*ast.StyleNode, error) {
|
||||
// Check for lang attribute in original source
|
||||
langPattern := regexp.MustCompile(`<style\s+lang="([^"]+)"`)
|
||||
matches := langPattern.FindStringSubmatch(p.source)
|
||||
lang := "css"
|
||||
if len(matches) > 1 {
|
||||
lang = matches[1]
|
||||
}
|
||||
|
||||
return &ast.StyleNode{
|
||||
Content: content,
|
||||
Lang: lang,
|
||||
Scoped: strings.Contains(p.source, "<style scoped") || strings.Contains(p.source, "<style lang=\"scss\" scoped"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isWhitespace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
922
compiler/internal/server/dev.go
Normal file
922
compiler/internal/server/dev.go
Normal file
@@ -0,0 +1,922 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/CarGDev/strata/internal/ast"
|
||||
"github.com/CarGDev/strata/internal/compiler"
|
||||
"github.com/CarGDev/strata/internal/parser"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// DevServer handles development server with HMR
|
||||
type DevServer struct {
|
||||
port int
|
||||
projectDir string
|
||||
distDir string
|
||||
clients map[*websocket.Conn]bool
|
||||
mu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
compiler *compiler.StaticCompiler
|
||||
}
|
||||
|
||||
// NewDevServer creates a new dev server
|
||||
func NewDevServer(port int, projectDir string) *DevServer {
|
||||
return &DevServer{
|
||||
port: port,
|
||||
projectDir: projectDir,
|
||||
distDir: filepath.Join(projectDir, ".strata", "dev"),
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins in dev
|
||||
},
|
||||
},
|
||||
compiler: compiler.NewStaticCompiler(projectDir),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the dev server
|
||||
func (s *DevServer) Start() error {
|
||||
// Create dev output directory
|
||||
if err := os.MkdirAll(s.distDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate initial files
|
||||
if err := s.generateDevFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up HTTP handlers
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// WebSocket for HMR
|
||||
mux.HandleFunc("/__strata_hmr", s.handleHMR)
|
||||
|
||||
// API proxy (if configured)
|
||||
mux.HandleFunc("/api/", s.handleAPIProxy)
|
||||
|
||||
// Static files from .strata/dev
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
fmt.Printf("\n Strata Dev Server\n")
|
||||
fmt.Printf(" ─────────────────────────────\n")
|
||||
fmt.Printf(" Local: http://localhost:%d\n", s.port)
|
||||
fmt.Printf(" Network: http://%s:%d\n", getLocalIP(), s.port)
|
||||
fmt.Printf("\n Watching for changes...\n\n")
|
||||
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
// handleStatic serves static files
|
||||
func (s *DevServer) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
// Try .strata/dev first
|
||||
filePath := filepath.Join(s.distDir, path)
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
http.ServeFile(w, r, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Try public folder
|
||||
publicPath := filepath.Join(s.projectDir, "public", path)
|
||||
if _, err := os.Stat(publicPath); err == nil {
|
||||
http.ServeFile(w, r, publicPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Try src/assets
|
||||
assetPath := filepath.Join(s.projectDir, "src", "assets", path)
|
||||
if _, err := os.Stat(assetPath); err == nil {
|
||||
http.ServeFile(w, r, assetPath)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback - serve index.html
|
||||
indexPath := filepath.Join(s.distDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// handleHMR handles WebSocket connections for HMR
|
||||
func (s *DevServer) handleHMR(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.clients[conn] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.clients, conn)
|
||||
s.mu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Keep connection alive
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIProxy proxies API requests
|
||||
func (s *DevServer) handleAPIProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Read API base URL from strataconfig.ts
|
||||
// For now, return 501
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
w.Write([]byte(`{"error": "API proxy not configured"}`))
|
||||
}
|
||||
|
||||
// Rebuild regenerates all dev files (called on file changes)
|
||||
func (s *DevServer) Rebuild() error {
|
||||
return s.generateDevFiles()
|
||||
}
|
||||
|
||||
// NotifyChange sends HMR update to all clients
|
||||
func (s *DevServer) NotifyChange(changeType string, path string) {
|
||||
message := map[string]string{
|
||||
"type": changeType,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for client := range s.clients {
|
||||
err := client.WriteMessage(websocket.TextMessage, data)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
delete(s.clients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateDevFiles generates initial dev files
|
||||
func (s *DevServer) generateDevFiles() error {
|
||||
// Generate index.html with HMR client
|
||||
html := s.generateDevHTML()
|
||||
indexPath := filepath.Join(s.distDir, "index.html")
|
||||
if err := os.WriteFile(indexPath, []byte(html), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate runtime.js
|
||||
runtime := s.generateDevRuntime()
|
||||
runtimePath := filepath.Join(s.distDir, "assets", "js", "runtime.js")
|
||||
if err := os.MkdirAll(filepath.Dir(runtimePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(runtimePath, []byte(runtime), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy and process source files
|
||||
return s.processSourceFiles()
|
||||
}
|
||||
|
||||
func (s *DevServer) generateDevHTML() string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Strata | Code Faster</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
window.__STRATA_CONFIG__ = {
|
||||
devMode: true,
|
||||
apiBaseUrl: 'http://localhost:8080'
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ascii-art {
|
||||
white-space: pre;
|
||||
line-height: 1.2;
|
||||
font-size: clamp(0.5rem, 1.5vw, 1rem);
|
||||
color: #3b82f6;
|
||||
text-shadow: 0 0 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.heart-btn {
|
||||
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.heart-btn:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.floating-heart {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
animation: floatUp 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes floatUp {
|
||||
0% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
100% { transform: translateY(-100px) scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.strata-error {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
color: #c00;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="/assets/js/runtime.js"></script>
|
||||
<script type="module" src="/assets/js/app.js"></script>
|
||||
|
||||
<!-- HMR Client -->
|
||||
<script>
|
||||
(function() {
|
||||
const ws = new WebSocket('ws://' + location.host + '/__strata_hmr');
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Strata HMR]', data.type, data.path);
|
||||
|
||||
if (data.type === 'reload') {
|
||||
location.reload();
|
||||
} else if (data.type === 'css') {
|
||||
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
links.forEach(link => {
|
||||
const url = new URL(link.href);
|
||||
url.searchParams.set('t', Date.now());
|
||||
link.href = url.toString();
|
||||
});
|
||||
} else if (data.type === 'component') {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log('[Strata HMR] Disconnected. Attempting reconnect...');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func (s *DevServer) generateDevRuntime() string {
|
||||
return `// Strata Runtime (Dev Mode)
|
||||
console.log('[Strata] Runtime loaded');
|
||||
|
||||
class Strata {
|
||||
constructor() {
|
||||
this._tabId = 'tab_' + Math.random().toString(36).slice(2, 10);
|
||||
this._stores = new Map();
|
||||
this._cache = new Map();
|
||||
}
|
||||
|
||||
get tabId() {
|
||||
return this._tabId;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Strata] Initialized with tabId:', this._tabId);
|
||||
window.__STRATA__ = this;
|
||||
}
|
||||
|
||||
// Simple fetch with caching
|
||||
async fetch(url, options = {}) {
|
||||
const cacheKey = url + JSON.stringify(options);
|
||||
|
||||
if (options.cache !== 'none' && this._cache.has(cacheKey)) {
|
||||
const cached = this._cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < 300000) { // 5 min
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
this._cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Broadcast to other tabs (simplified for dev)
|
||||
broadcast(event, data) {
|
||||
console.log('[Strata] Broadcast:', event, data);
|
||||
window.dispatchEvent(new CustomEvent('strata:' + event, { detail: data }));
|
||||
}
|
||||
|
||||
onBroadcast(event, handler) {
|
||||
window.addEventListener('strata:' + event, (e) => handler(e.detail));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
const strata = new Strata();
|
||||
strata.init();
|
||||
|
||||
export { strata };
|
||||
export default strata;
|
||||
`
|
||||
}
|
||||
|
||||
func (s *DevServer) processSourceFiles() error {
|
||||
// Process .strata files in src/pages
|
||||
pagesDir := filepath.Join(s.projectDir, "src", "pages")
|
||||
if _, err := os.Stat(pagesDir); err != nil {
|
||||
return nil // No pages directory
|
||||
}
|
||||
|
||||
// Find all pages - both directory structure (pages/name/name.strata) and flat (pages/name.strata)
|
||||
var pages []PageInfo
|
||||
processedDirs := make(map[string]bool)
|
||||
|
||||
err := filepath.Walk(pagesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for directory-based pages first (pages/name/name.strata)
|
||||
if info.IsDir() && path != pagesDir {
|
||||
dirName := filepath.Base(path)
|
||||
strataFile := filepath.Join(path, dirName+".strata")
|
||||
compilerFile := filepath.Join(path, dirName+".compiler.sts")
|
||||
|
||||
// If this is a page directory with .strata file
|
||||
if _, err := os.Stat(strataFile); err == nil {
|
||||
processedDirs[path] = true
|
||||
|
||||
// Check if it has a .compiler.sts file (new structure)
|
||||
if _, err := os.Stat(compilerFile); err == nil {
|
||||
// Use static compiler for new structure
|
||||
pageInfo, err := s.compilePageDirectory(path)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
} else {
|
||||
// Fallback to old compilation for legacy structure
|
||||
pageInfo, err := s.compilePage(strataFile)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", strataFile, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
}
|
||||
return filepath.SkipDir // Don't descend further
|
||||
}
|
||||
}
|
||||
|
||||
// Handle flat structure (pages/name.strata)
|
||||
if !info.IsDir() && strings.HasSuffix(path, ".strata") {
|
||||
// Skip if parent dir was already processed as a page directory
|
||||
if processedDirs[filepath.Dir(path)] {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageInfo, err := s.compilePage(path)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to compile %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
pages = append(pages, pageInfo)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate app.js from compiled pages
|
||||
appJS := s.generateAppJSFromPages(pages)
|
||||
appPath := filepath.Join(s.distDir, "assets", "js", "app.js")
|
||||
if err := os.WriteFile(appPath, []byte(appJS), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate combined CSS
|
||||
cssPath := filepath.Join(s.distDir, "assets", "css", "app.css")
|
||||
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
css := s.generateCSSFromPages(pages)
|
||||
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// compilePageDirectory compiles a page from directory structure using static compiler
|
||||
func (s *DevServer) compilePageDirectory(pageDir string) (PageInfo, error) {
|
||||
pageName := filepath.Base(pageDir)
|
||||
|
||||
// Use the static compiler
|
||||
module, err := s.compiler.CompilePage(pageDir)
|
||||
if err != nil {
|
||||
return PageInfo{}, err
|
||||
}
|
||||
|
||||
// Read service file for runtime logic (if exists)
|
||||
serviceContent := ""
|
||||
servicePath := filepath.Join(pageDir, pageName+".service.sts")
|
||||
if content, err := os.ReadFile(servicePath); err == nil {
|
||||
serviceContent = string(content)
|
||||
}
|
||||
|
||||
// Calculate route from directory path
|
||||
relPath, _ := filepath.Rel(filepath.Join(s.projectDir, "src", "pages"), pageDir)
|
||||
route := "/" + relPath
|
||||
if pageName == "index" || relPath == "index" {
|
||||
route = "/"
|
||||
}
|
||||
|
||||
return PageInfo{
|
||||
Name: pageName,
|
||||
Path: pageDir,
|
||||
HTML: module.HTML,
|
||||
Style: module.CSS,
|
||||
Route: route,
|
||||
Compiler: "", // Already resolved in HTML
|
||||
Service: serviceContent,
|
||||
Imports: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PageInfo holds compiled page information
|
||||
type PageInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
HTML string
|
||||
Compiler string // .compiler.sts - variable definitions
|
||||
Service string // .service.sts - business logic
|
||||
Script string // Legacy .sts support
|
||||
Style string
|
||||
Route string
|
||||
Imports []string // Imported components
|
||||
}
|
||||
|
||||
// compilePage compiles a single .strata file and its associated files
|
||||
// File structure:
|
||||
// - page.strata - HTML template
|
||||
// - page.compiler.sts - variable definitions (like Angular component)
|
||||
// - page.service.sts - business logic (like Angular service)
|
||||
// - page.scss - styles
|
||||
func (s *DevServer) compilePage(strataPath string) (PageInfo, error) {
|
||||
info := PageInfo{
|
||||
Path: strataPath,
|
||||
Imports: make([]string, 0),
|
||||
}
|
||||
|
||||
// Get base name without extension
|
||||
baseName := strings.TrimSuffix(filepath.Base(strataPath), ".strata")
|
||||
info.Name = baseName
|
||||
|
||||
// Calculate route from file path
|
||||
relPath, _ := filepath.Rel(filepath.Join(s.projectDir, "src", "pages"), strataPath)
|
||||
route := "/" + strings.TrimSuffix(relPath, ".strata")
|
||||
if strings.HasSuffix(route, "/index") {
|
||||
route = strings.TrimSuffix(route, "/index")
|
||||
if route == "" {
|
||||
route = "/"
|
||||
}
|
||||
}
|
||||
info.Route = route
|
||||
|
||||
// Read and parse .strata file
|
||||
strataContent, err := os.ReadFile(strataPath)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
p := parser.NewStrataParser(string(strataContent))
|
||||
file, err := p.Parse()
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Generate HTML from template
|
||||
if file.Template != nil {
|
||||
info.HTML = s.generateHTMLFromTemplate(file.Template)
|
||||
|
||||
// Extract imports from parsed template
|
||||
info.Imports = s.extractImports(file.Template)
|
||||
}
|
||||
|
||||
// Check for .compiler.sts file (variable definitions - Angular-like component)
|
||||
compilerPath := strings.TrimSuffix(strataPath, ".strata") + ".compiler.sts"
|
||||
if compilerContent, err := os.ReadFile(compilerPath); err == nil {
|
||||
info.Compiler = string(compilerContent)
|
||||
}
|
||||
|
||||
// Check for .service.sts file (business logic - Angular-like service)
|
||||
servicePath := strings.TrimSuffix(strataPath, ".strata") + ".service.sts"
|
||||
if serviceContent, err := os.ReadFile(servicePath); err == nil {
|
||||
info.Service = string(serviceContent)
|
||||
}
|
||||
|
||||
// Legacy: Check for .sts file (combined script)
|
||||
stsPath := strings.TrimSuffix(strataPath, ".strata") + ".sts"
|
||||
if stsContent, err := os.ReadFile(stsPath); err == nil {
|
||||
info.Script = string(stsContent)
|
||||
} else if file.Script != nil {
|
||||
info.Script = file.Script.Content
|
||||
}
|
||||
|
||||
// Check for associated .scss file (styles)
|
||||
scssPath := strings.TrimSuffix(strataPath, ".strata") + ".scss"
|
||||
if scssContent, err := os.ReadFile(scssPath); err == nil {
|
||||
info.Style = string(scssContent)
|
||||
} else if file.Style != nil {
|
||||
info.Style = file.Style.Content
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// extractImports extracts import paths from a template's ImportNodes
|
||||
func (s *DevServer) extractImports(template interface{}) []string {
|
||||
imports := make([]string, 0)
|
||||
|
||||
if t, ok := template.(*ast.TemplateNode); ok {
|
||||
for _, child := range t.Children {
|
||||
if imp, ok := child.(*ast.ImportNode); ok {
|
||||
imports = append(imports, imp.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
// generateHTMLFromTemplate converts AST template to HTML string
|
||||
func (s *DevServer) generateHTMLFromTemplate(template interface{}) string {
|
||||
if t, ok := template.(interface{ ToHTML() string }); ok {
|
||||
return t.ToHTML()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateAppJSFromPages creates the app.js from compiled pages
|
||||
func (s *DevServer) generateAppJSFromPages(pages []PageInfo) string {
|
||||
if len(pages) == 0 {
|
||||
return s.generateAppJS() // Fallback to default
|
||||
}
|
||||
|
||||
var js strings.Builder
|
||||
js.WriteString(`// Strata App - Static Compilation Output
|
||||
// All templates resolved at build time - no Strata syntax in runtime HTML
|
||||
import { strata } from './runtime.js';
|
||||
|
||||
`)
|
||||
|
||||
// Generate page modules
|
||||
for i, page := range pages {
|
||||
// Escape the HTML for JavaScript
|
||||
escapedHTML := strings.ReplaceAll(page.HTML, "`", "\\`")
|
||||
escapedHTML = strings.ReplaceAll(escapedHTML, "${", "\\${")
|
||||
|
||||
// Check if this is a statically compiled page (Compiler is empty means already resolved)
|
||||
// Pages can still have Service for runtime interactivity
|
||||
isStaticCompiled := page.Compiler == ""
|
||||
|
||||
if isStaticCompiled {
|
||||
// Statically compiled page - HTML is already resolved
|
||||
// May have service file for runtime interactivity
|
||||
if page.Service != "" {
|
||||
// Extract the mount function from service file
|
||||
escapedService := strings.ReplaceAll(page.Service, "`", "\\`")
|
||||
escapedService = strings.ReplaceAll(escapedService, "${", "\\${")
|
||||
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (statically compiled with runtime service)
|
||||
const page%dService = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
console.log('[Strata] Static page mounted:', this.name);
|
||||
// Execute service mount if defined
|
||||
if (page%dService && page%dService.mount) {
|
||||
page%dService.mount();
|
||||
} else if (page%dService && page%dService.default && page%dService.default.mount) {
|
||||
page%dService.default.mount();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, escapedService, i, page.Name, page.Route, escapedHTML, i, i, i, i, i, i, i))
|
||||
} else {
|
||||
// Pure static page - no runtime code
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (statically compiled)
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
console.log('[Strata] Static page mounted:', this.name);
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, page.Name, page.Route, escapedHTML))
|
||||
}
|
||||
} else {
|
||||
// Legacy page with runtime compilation
|
||||
compilerContent := page.Compiler
|
||||
if compilerContent == "" && page.Script != "" {
|
||||
compilerContent = page.Script
|
||||
}
|
||||
if compilerContent == "" {
|
||||
compilerContent = "// No compiler defined\nreturn {};"
|
||||
}
|
||||
|
||||
serviceContent := page.Service
|
||||
if serviceContent == "" {
|
||||
serviceContent = "// No service defined\nreturn {};"
|
||||
}
|
||||
|
||||
js.WriteString(fmt.Sprintf(`// Page: %s (runtime)
|
||||
const page%dCompiler = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%dService = (function() {
|
||||
%s
|
||||
})();
|
||||
|
||||
const page%d = {
|
||||
name: "%s",
|
||||
route: "%s",
|
||||
compiler: page%dCompiler,
|
||||
service: page%dService,
|
||||
render() {
|
||||
return `+"`%s`"+`;
|
||||
},
|
||||
mount() {
|
||||
const state = this.compiler.state || this.compiler.default || {};
|
||||
const methods = this.service.methods || this.service.default || {};
|
||||
|
||||
// Bind event handlers
|
||||
document.querySelectorAll('[data-strata-\\@click]').forEach(el => {
|
||||
const handler = el.getAttribute('data-strata-@click');
|
||||
if (methods[handler]) {
|
||||
el.addEventListener('click', (e) => methods[handler].call({ state, methods }, e));
|
||||
}
|
||||
});
|
||||
|
||||
// Bind interpolation placeholders
|
||||
document.querySelectorAll('[data-strata-bind]').forEach(el => {
|
||||
const expr = el.getAttribute('data-strata-bind');
|
||||
const value = expr.split('.').reduce((o, k) => o && o[k], state);
|
||||
if (value !== undefined) el.textContent = value;
|
||||
});
|
||||
|
||||
console.log('[Strata] Page mounted:', this.name);
|
||||
}
|
||||
};
|
||||
|
||||
`, page.Name, i, compilerContent, i, serviceContent,
|
||||
i, page.Name, page.Route, i, i, escapedHTML))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate pages map
|
||||
js.WriteString(`// Pages registry
|
||||
const pages = {
|
||||
`)
|
||||
for i, page := range pages {
|
||||
js.WriteString(fmt.Sprintf(` "%s": page%d,
|
||||
`, page.Route, i))
|
||||
}
|
||||
js.WriteString(`};
|
||||
|
||||
// Router
|
||||
function getRoute() {
|
||||
const path = window.location.pathname || '/';
|
||||
return path === '' ? '/' : path;
|
||||
}
|
||||
|
||||
// Mount application
|
||||
async function mount() {
|
||||
const app = document.getElementById('app');
|
||||
const route = getRoute();
|
||||
const page = pages[route] || pages['/'];
|
||||
|
||||
if (!page) {
|
||||
app.innerHTML = '<div class="strata-error">Page not found: ' + route + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Render HTML
|
||||
app.innerHTML = page.render({});
|
||||
|
||||
// Mount page (bind events, etc)
|
||||
page.mount();
|
||||
|
||||
console.log('[Strata] Mounted:', page.name, 'at', route);
|
||||
} catch (error) {
|
||||
app.innerHTML = '<div class="strata-error">' + error.message + '</div>';
|
||||
console.error('[Strata] Mount error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
mount();
|
||||
|
||||
// Handle navigation
|
||||
window.addEventListener('popstate', mount);
|
||||
`)
|
||||
|
||||
return js.String()
|
||||
}
|
||||
|
||||
// generateCSSFromPages combines all page styles
|
||||
func (s *DevServer) generateCSSFromPages(pages []PageInfo) string {
|
||||
var css strings.Builder
|
||||
|
||||
// Add global styles first
|
||||
globalPath := filepath.Join(s.projectDir, "src", "assets", "styles", "global.scss")
|
||||
if globalContent, err := os.ReadFile(globalPath); err == nil {
|
||||
css.WriteString("/* Global styles */\n")
|
||||
css.WriteString(string(globalContent))
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add variables
|
||||
varsPath := filepath.Join(s.projectDir, "src", "assets", "styles", "_variables.scss")
|
||||
if varsContent, err := os.ReadFile(varsPath); err == nil {
|
||||
css.WriteString("/* Variables */\n")
|
||||
css.WriteString(string(varsContent))
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add page styles
|
||||
for _, page := range pages {
|
||||
if page.Style != "" {
|
||||
css.WriteString(fmt.Sprintf("/* Page: %s */\n", page.Name))
|
||||
css.WriteString(page.Style)
|
||||
css.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return css.String()
|
||||
}
|
||||
|
||||
func (s *DevServer) generateAppJS() string {
|
||||
return `// Strata App (Dev Mode)
|
||||
import { strata } from './runtime.js';
|
||||
|
||||
async function mount() {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
try {
|
||||
app.innerHTML = ` + "`" + `
|
||||
<!-- Header / Branding -->
|
||||
<div class="mb-8 text-center animate-pulse">
|
||||
<div class="ascii-art mb-4">
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ███████╗████████╗██████╗ █████╗ ████████╗ █████╗ ║
|
||||
║ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ║
|
||||
║ ███████╗ ██║ ██████╔╝███████║ ██║ ███████║ ║
|
||||
║ ╚════██║ ██║ ██╔══██╗██╔══██║ ██║ ██╔══██║ ║
|
||||
║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ██║ ██║ ║
|
||||
║ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ Static Template Rendering Architecture ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-4xl font-bold mb-2">Welcome to <span class="gradient-text">Strata</span></h1>
|
||||
<p class="text-gray-400 text-sm md:text-base">Code Faster. Load Quick. Deploy ASAP.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Interaction -->
|
||||
<div class="relative flex flex-col items-center">
|
||||
<button id="heartBtn" class="heart-btn bg-white/5 hover:bg-white/10 border border-white/10 rounded-full p-8 mb-4 backdrop-blur-sm shadow-2xl group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-red-500 group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<span id="counter" class="text-5xl font-bold tabular-nums">0</span>
|
||||
<p class="text-gray-500 mt-2 uppercase tracking-widest text-xs">Hearts Captured</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab ID Badge -->
|
||||
<div class="fixed top-4 right-4 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-400">
|
||||
Tab: <code class="text-blue-400">${strata.tabId}</code>
|
||||
</div>
|
||||
|
||||
<!-- Background Decoration -->
|
||||
<div class="fixed bottom-4 text-gray-700 text-[10px] uppercase tracking-tighter opacity-50">
|
||||
STRATA_ENGINE_V1.0 // FAST_PATH_ENABLED // HMR_ACTIVE
|
||||
</div>
|
||||
` + "`" + `;
|
||||
|
||||
// Initialize heart button interaction
|
||||
const btn = document.getElementById('heartBtn');
|
||||
const counterEl = document.getElementById('counter');
|
||||
let count = 0;
|
||||
|
||||
function createHeart(x, y) {
|
||||
const heart = document.createElement('div');
|
||||
heart.className = 'floating-heart text-red-500';
|
||||
heart.innerHTML = '<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>';
|
||||
heart.style.left = x + 'px';
|
||||
heart.style.top = y + 'px';
|
||||
document.body.appendChild(heart);
|
||||
setTimeout(() => heart.remove(), 1000);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
count++;
|
||||
counterEl.innerText = count;
|
||||
createHeart(e.clientX - 12, e.clientY - 12);
|
||||
counterEl.style.transform = 'scale(1.1)';
|
||||
setTimeout(() => counterEl.style.transform = 'scale(1)', 100);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' || e.code === 'Enter') btn.click();
|
||||
});
|
||||
|
||||
console.log('[Strata] App mounted with tabId:', strata.tabId);
|
||||
} catch (error) {
|
||||
app.innerHTML = '<div class="strata-error">' + error.message + '</div>';
|
||||
console.error('[Strata] Mount error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
mount();
|
||||
`
|
||||
}
|
||||
|
||||
func getLocalIP() string {
|
||||
// Simplified - return localhost for now
|
||||
return "localhost"
|
||||
}
|
||||
170
compiler/internal/watcher/watcher.go
Normal file
170
compiler/internal/watcher/watcher.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeStrata FileType = ".strata"
|
||||
FileTypeSTS FileType = ".sts"
|
||||
FileTypeSCSS FileType = ".scss"
|
||||
FileTypeTS FileType = ".ts"
|
||||
FileTypeConfig FileType = "strataconfig.ts"
|
||||
)
|
||||
|
||||
type ChangeEvent struct {
|
||||
Path string
|
||||
Type FileType
|
||||
Op fsnotify.Op
|
||||
IsConfig bool
|
||||
}
|
||||
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
srcDir string
|
||||
onChange func(ChangeEvent)
|
||||
debounce time.Duration
|
||||
pending map[string]ChangeEvent
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func New(srcDir string, onChange func(ChangeEvent)) (*Watcher, error) {
|
||||
fsWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
watcher: fsWatcher,
|
||||
srcDir: srcDir,
|
||||
onChange: onChange,
|
||||
debounce: 50 * time.Millisecond,
|
||||
pending: make(map[string]ChangeEvent),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) Start() error {
|
||||
// Add src directory recursively
|
||||
err := filepath.Walk(w.srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return w.watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Watch strataconfig.ts in root
|
||||
configPath := filepath.Join(filepath.Dir(w.srcDir), "strataconfig.ts")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
w.watcher.Add(filepath.Dir(configPath))
|
||||
}
|
||||
|
||||
go w.run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Watcher) run() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.handleEvent(event)
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("Watcher error: %v", err)
|
||||
|
||||
case <-w.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
// Skip non-relevant events
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file type
|
||||
fileType := w.getFileType(event.Name)
|
||||
if fileType == "" {
|
||||
return
|
||||
}
|
||||
|
||||
isConfig := strings.HasSuffix(event.Name, "strataconfig.ts")
|
||||
|
||||
change := ChangeEvent{
|
||||
Path: event.Name,
|
||||
Type: fileType,
|
||||
Op: event.Op,
|
||||
IsConfig: isConfig,
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.pending[event.Name] = change
|
||||
|
||||
// Reset debounce timer
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
}
|
||||
w.timer = time.AfterFunc(w.debounce, w.flush)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *Watcher) flush() {
|
||||
w.mu.Lock()
|
||||
pending := w.pending
|
||||
w.pending = make(map[string]ChangeEvent)
|
||||
w.mu.Unlock()
|
||||
|
||||
for _, event := range pending {
|
||||
w.onChange(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) getFileType(path string) FileType {
|
||||
ext := filepath.Ext(path)
|
||||
switch ext {
|
||||
case ".strata":
|
||||
return FileTypeStrata
|
||||
case ".sts":
|
||||
return FileTypeSTS
|
||||
case ".scss":
|
||||
return FileTypeSCSS
|
||||
case ".ts":
|
||||
if strings.HasSuffix(path, "strataconfig.ts") {
|
||||
return FileTypeConfig
|
||||
}
|
||||
return FileTypeTS
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *Watcher) Stop() {
|
||||
close(w.stopCh)
|
||||
w.watcher.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user