Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/compiler/generated-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ The public `gowdk manifest` command is documented in
- Keep generated Go deterministic and formatted with `go/format`.
- Keep generated browser code compiler-owned; user JavaScript remains optional
assets or explicit page code.
- Disk static output and generated app source are planned before publication.
Final file replacements use same-directory temporary files followed by atomic
rename, so validation, formatting, manifest/report generation, and security
manifest generation fail before touching committed files.
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope the publication guarantee to non-incremental builds

This new rule says disk static output is planned before publication, but gowdk dev still uses BuildIncrementalFromValidatedProgram (internal/gowdkcmd/dev_loop.go:131), whose incremental path writes CSS/runtime/page files as it goes before later cleanup and manifest/report steps. In incremental rebuilds, a late error can still partially update the served output, so the documented contract is broader than the implementation; either apply the planned-publish path to incremental builds or explicitly scope this guarantee to full static builds.

Useful? React with 👍 / 👎.

- Document new generated files in this page, the build report page, or the
reference page that owns the public contract.
- New generated artifact kinds must declare whether they are public,
Expand Down
78 changes: 53 additions & 25 deletions internal/appgen/appgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,24 +127,23 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re
if isSameOrWithin(targetOutput, absOutput) {
return Result{}, fmt.Errorf("build output directory %q must not be inside generated app output directory %q", absOutput, targetOutput)
}
if err := os.MkdirAll(absApp, 0o755); err != nil {
return Result{}, err
}
moduleContext := resolveGeneratedModuleContext(absApp)
if err := os.MkdirAll(targetOutput, 0o755); err != nil {
return Result{}, err
}

files, err := copyOutputFiles(absOutput, targetOutput)
files, outputFiles, err := collectOutputFiles(absOutput, targetOutput)
if err != nil {
return Result{}, err
}
if err := removeStaleOutputFiles(targetOutput, files); err != nil {
return Result{}, err
}
modulePath, err := writeGeneratedModuleFile(absApp, moduleContext, options)
if err != nil {
return Result{}, err
plannedFiles := append([]plannedFile(nil), outputFiles...)
var removeAfterPublish []string
modulePath := filepath.Join(absApp, modFileName)
if moduleContext.Nested {
modulePayload, err := moduleSource(options)
if err != nil {
return Result{}, err
}
plannedFiles = append(plannedFiles, plannedFile{path: modulePath, contents: []byte(modulePayload)})
} else {
removeAfterPublish = append(removeAfterPublish, modulePath)
modulePath = ""
}
packageSource, err := appPackageSource(options)
if err != nil {
Expand All @@ -154,41 +153,70 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re
if err != nil {
return Result{}, err
}
if err := writeFileIfChanged(filepath.Join(absApp, appFileName), appSource); err != nil {
plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, appFileName), contents: appSource})
lifecycleSources, err := lifecycleServiceFileSources(options)
if err != nil {
return Result{}, err
}
if err := writeLifecycleServiceFiles(absApp, options); err != nil {
return Result{}, err
for _, name := range []string{lifecycleFileName, lifecycleJSName} {
path := filepath.Join(absApp, name)
source, ok := lifecycleSources[name]
if !ok {
removeAfterPublish = append(removeAfterPublish, path)
continue
}
formatted, err := formatGeneratedGo(name, source)
if err != nil {
return Result{}, err
}
plannedFiles = append(plannedFiles, plannedFile{path: path, contents: formatted})
}
auditTestSource, err := GeneratedAuditTestSource(options)
if err != nil {
return Result{}, err
}
auditTestPath := filepath.Join(absApp, auditTestFileName)
if len(auditTestSource) > 0 {
if err := writeFileIfChanged(auditTestPath, auditTestSource); err != nil {
return Result{}, err
}
} else if err := os.Remove(auditTestPath); err != nil && !os.IsNotExist(err) {
return Result{}, err
plannedFiles = append(plannedFiles, plannedFile{path: auditTestPath, contents: auditTestSource})
} else {
removeAfterPublish = append(removeAfterPublish, auditTestPath)
}
scriptFiles, err := writeInlineGoBlockFiles(absApp, options)
scriptFiles, scriptPlannedFiles, err := collectInlineGoBlockFiles(absApp, options)
if err != nil {
return Result{}, err
}
addonGoBlockFiles, err := writeAddonGoBlockFiles(absApp, options)
plannedFiles = append(plannedFiles, scriptPlannedFiles...)
addonGoBlockFiles, addonPlannedFiles, err := collectAddonGoBlockFiles(absApp, options)
if err != nil {
return Result{}, err
}
plannedFiles = append(plannedFiles, addonPlannedFiles...)
files = append(files, scriptFiles...)
files = append(files, addonGoBlockFiles...)
mainSource, err := serverMainSource(moduleContext.ImportBase + "/" + appPackageDirName)
if err != nil {
return Result{}, err
}
if err := writeFileIfChanged(filepath.Join(absApp, mainFileName), []byte(mainSource)); err != nil {
plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, mainFileName), contents: []byte(mainSource)})
if err := os.MkdirAll(absApp, 0o755); err != nil {
return Result{}, err
}
if err := os.MkdirAll(targetOutput, 0o755); err != nil {
return Result{}, err
}
for _, file := range plannedFiles {
if err := writeFileIfChanged(file.path, file.contents); err != nil {
return Result{}, err
}
}
if err := removeStaleOutputFiles(targetOutput, files); err != nil {
return Result{}, err
}
for _, path := range removeAfterPublish {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return Result{}, err
}
}

return Result{
AppDir: absApp,
Expand Down
6 changes: 6 additions & 0 deletions internal/appgen/appgen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6332,6 +6332,12 @@ func TestGenerateRejectsUnsupportedAddonGoBlockTarget(t *testing.T) {
if !strings.Contains(err.Error(), "requires an enabled addon implementing gowdk.GoBlockConsumer") {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(filepath.Join(appDir, appOutputDirName, "index.html")); !os.IsNotExist(statErr) {
t.Fatalf("expected embedded output to stay unpublished after planning failure, stat err=%v", statErr)
}
if _, statErr := os.Stat(filepath.Join(appDir, appFileName)); !os.IsNotExist(statErr) {
t.Fatalf("expected generated app source to stay unpublished after planning failure, stat err=%v", statErr)
}
}

type appgenGoBlockAddon struct{}
Expand Down
55 changes: 51 additions & 4 deletions internal/appgen/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import (
"github.com/cssbruno/gowdk/internal/safeasset"
)

type plannedFile struct {
path string
contents []byte
}

func validateDirectories(outputDir, appDir string) error {
info, err := os.Stat(outputDir)
if err != nil {
Expand Down Expand Up @@ -38,7 +43,21 @@ func isSameOrWithin(parent, child string) bool {
}

func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) {
files, planned, err := collectOutputFiles(sourceRoot, targetRoot)
if err != nil {
return nil, err
}
for _, file := range planned {
if err := writeFileIfChanged(file.path, file.contents); err != nil {
return nil, err
}
}
return files, nil
}

func collectOutputFiles(sourceRoot, targetRoot string) ([]string, []plannedFile, error) {
var files []string
var planned []plannedFile
err := filepath.WalkDir(sourceRoot, func(sourcePath string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
Expand All @@ -56,7 +75,7 @@ func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) {
if unsafeEmbeddedDirectory(rel) {
return filepath.SkipDir
}
return os.MkdirAll(targetPath, 0o755)
return nil
}
info, err := entry.Info()
if err != nil {
Expand All @@ -68,14 +87,16 @@ func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) {
if !safeasset.EmbeddableGeneratedOutputFile(rel) {
return nil
}
if err := copyFile(sourcePath, targetPath); err != nil {
payload, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
planned = append(planned, plannedFile{path: targetPath, contents: payload})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid buffering every embedded asset

For generated app builds with many or large embeddable assets, this now retains the contents of every copied output file in planned until all planning finishes, whereas the previous path only held one file at a time. Large static outputs can therefore spike memory or fail app generation before publication; keep the plan as paths/temp files or stream each file into a staged temp instead of accumulating all payloads in memory.

Useful? React with 👍 / 👎.

files = append(files, rel)
return nil
})
sort.Strings(files)
return files, err
return files, planned, err
}

func unsafeEmbeddedDirectory(rel string) bool {
Expand Down Expand Up @@ -125,5 +146,31 @@ func writeFileIfChanged(filePath string, contents []byte) error {
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return err
}
return os.WriteFile(filePath, contents, 0o644)
temp, err := os.CreateTemp(filepath.Dir(filePath), "."+filepath.Base(filePath)+".tmp-*")
if err != nil {
return err
}
tempName := temp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tempName)
}
}()
if _, err := temp.Write(contents); err != nil {
_ = temp.Close()
return err
}
if err := temp.Chmod(0o644); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempName, filePath); err != nil {
return err
}
cleanup = false
return nil
}
56 changes: 40 additions & 16 deletions internal/appgen/scripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,41 @@ type addonGoBlockTarget struct {
}

func writeInlineGoBlockFiles(appDir string, options Options) ([]string, error) {
files, planned, err := collectInlineGoBlockFiles(appDir, options)
if err != nil {
return nil, err
}
for _, file := range planned {
if err := writeFileIfChanged(file.path, file.contents); err != nil {
return nil, err
}
}
return files, nil
}

func collectInlineGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) {
if options.IR == nil {
return nil, nil
return nil, nil, nil
}
groups := inlineGoBlockGroups(*options.IR)
if len(groups) == 0 {
return nil, nil
return nil, nil, nil
}
var files []string
var planned []plannedFile
for packageName, group := range groups {
generated, err := goblockgen.Source(packageName, group.imports, group.goBlocks)
if err != nil {
return nil, fmt.Errorf("generate inline go block package %s: %w", packageName, err)
return nil, nil, fmt.Errorf("generate inline go block package %s: %w", packageName, err)
}
if len(generated) == 0 {
continue
}
relPath := goblockgen.GeneratedRelPath(packageName)
if err := writeFileIfChanged(filepath.Join(appDir, relPath), generated); err != nil {
return nil, err
}
planned = append(planned, plannedFile{path: filepath.Join(appDir, relPath), contents: generated})
files = append(files, relPath)
}
return files, nil
return files, planned, nil
}

func inlineGoBlockGroups(ir gwdkir.Program) map[string]inlineGoBlockGroup {
Expand Down Expand Up @@ -117,39 +129,51 @@ func mergeGoBlockImports(left []gwdkir.Import, right []gwdkir.Import) []gwdkir.I
}

func writeAddonGoBlockFiles(appDir string, options Options) ([]string, error) {
files, planned, err := collectAddonGoBlockFiles(appDir, options)
if err != nil {
return nil, err
}
for _, file := range planned {
if err := writeFileIfChanged(file.path, file.contents); err != nil {
return nil, err
}
}
return files, nil
}

func collectAddonGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) {
if options.IR == nil {
return nil, nil
return nil, nil, nil
}
var files []string
var planned []plannedFile
for _, target := range addonGoBlockTargets(*options.IR, options.Config) {
consumer, ok := addonGoBlockConsumer(options.Config, target.target.Target)
if !ok {
return nil, fmt.Errorf("go block target %s requires an enabled addon implementing gowdk.GoBlockConsumer", target.target.Target)
return nil, nil, fmt.Errorf("go block target %s requires an enabled addon implementing gowdk.GoBlockConsumer", target.target.Target)
}
generated, err := consumer.GeneratedGo(target.target, gowdk.GoBlockContext{Render: target.render})
if err != nil {
return nil, fmt.Errorf("generate addon go block target %s: %w", target.target.Target, err)
return nil, nil, fmt.Errorf("generate addon go block target %s: %w", target.target.Target, err)
}
for _, file := range generated {
relPath, err := safeAddonGoBlockFilePath(file.Path)
if err != nil {
return nil, err
return nil, nil, err
}
contents := []byte(file.Source)
if strings.HasSuffix(relPath, ".go") {
formatted, err := format.Source(contents)
if err != nil {
return nil, fmt.Errorf("format addon go block file %s: %w", relPath, err)
return nil, nil, fmt.Errorf("format addon go block file %s: %w", relPath, err)
}
contents = formatted
}
if err := writeFileIfChanged(filepath.Join(appDir, relPath), contents); err != nil {
return nil, err
}
planned = append(planned, plannedFile{path: filepath.Join(appDir, relPath), contents: contents})
files = append(files, relPath)
}
}
return files, nil
return files, planned, nil
}

func addonGoBlockConsumer(config gowdk.Config, target string) (gowdk.GoBlockConsumer, bool) {
Expand Down
Loading
Loading